create-pmos 0.8.0 → 0.10.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/README.md +89 -0
- package/bin/create-pmos.js +69 -11
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/skills/pmos-router.skill +22 -0
- package/payload/skills-native/pmos-router/SKILL.md +22 -0
- package/workspace/README.md +28 -0
- package/workspace/bin/pmos.js +9 -2
- package/workspace/lib/console-state.js +56 -1
- package/workspace/lib/git-info.js +197 -25
- package/workspace/lib/related-docs.js +107 -0
- package/workspace/lib/session-state.js +139 -0
- package/workspace/lib/transcript-read.js +28 -6
- package/workspace/package.json +1 -1
- package/workspace/public/app.js +434 -61
- package/workspace/public/index.html +24 -3
- package/workspace/public/styles.css +909 -572
- package/workspace/public/vendor/ibm-plex-mono-400.woff2 +0 -0
- package/workspace/public/vendor/ibm-plex-mono-700.woff2 +0 -0
- package/workspace/public/vendor/space-grotesk-var.woff2 +0 -0
- package/workspace/server.js +566 -39
|
@@ -1,25 +1,51 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
// Read-mostly git helpers for the workspace (branch awareness + select/create at session start).
|
|
3
3
|
// Everything here shells `git` via spawnSync with an ARGS ARRAY — never a shell string — so no
|
|
4
|
-
// caller input is ever interpolated into a command line.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// caller input is ever interpolated into a command line.
|
|
5
|
+
//
|
|
6
|
+
// Two truths this module exists to keep straight:
|
|
7
|
+
// 1. A workspace's working tree is SHARED by every session in it (D62), so `checkout` is a
|
|
8
|
+
// property of the REPO, not a session: switching moves every session in that repo — and every
|
|
9
|
+
// OTHER workspace registered under the same repo too. Callers surface that; we never pretend.
|
|
10
|
+
// 2. The way OUT of that shared-tree constraint is a linked worktree: `addWorktree` gives a
|
|
11
|
+
// branch its own directory, so a new session can start on a new branch while every running
|
|
12
|
+
// session stays exactly where it was. That is the isolated path, and it is the default the
|
|
13
|
+
// UI offers for "+ new branch".
|
|
14
|
+
//
|
|
15
|
+
// "Is this a repo?" is answered by `git rev-parse`, NOT by looking for a `.git` entry: a workspace
|
|
16
|
+
// registered at a SUBDIRECTORY of a repo (common for a tenant product repo added by path) is
|
|
17
|
+
// inside git, and reporting "not a git repository" there was a lie that cost the PM real time.
|
|
7
18
|
const fs = require('fs');
|
|
8
19
|
const path = require('path');
|
|
9
20
|
const { spawnSync } = require('child_process');
|
|
10
21
|
|
|
11
22
|
const GIT_TIMEOUT = 5000; // never let a slow/hung repo (huge tree, network remote) block the server
|
|
12
23
|
|
|
13
|
-
function
|
|
14
|
-
// a normal repo has a `.git` dir; a linked worktree has a `.git` FILE — both count.
|
|
15
|
-
try { return !!root && fs.existsSync(path.join(root, '.git')); } catch { return false; }
|
|
16
|
-
}
|
|
24
|
+
function isDir(p) { try { return fs.statSync(p).isDirectory(); } catch { return false; } }
|
|
17
25
|
|
|
18
26
|
function git(root, args) {
|
|
19
27
|
const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: GIT_TIMEOUT });
|
|
20
28
|
return { code: r.status, out: (r.stdout || '').trim(), err: (r.stderr || '').trim(), failed: !!r.error };
|
|
21
29
|
}
|
|
22
30
|
|
|
31
|
+
// The repo's top-level directory, or null when `root` is not inside a repo. Works from any depth,
|
|
32
|
+
// which is the whole point: a workspace at repo/packages/app is still in git.
|
|
33
|
+
function repoRoot(root) {
|
|
34
|
+
if (!root || !isDir(root)) return null;
|
|
35
|
+
const r = git(root, ['rev-parse', '--show-toplevel']);
|
|
36
|
+
if (r.failed || r.code !== 0 || !r.out) return null;
|
|
37
|
+
const top = r.out.split('\n')[0].trim();
|
|
38
|
+
return top ? path.resolve(top) : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isRepo(root) {
|
|
42
|
+
if (!root) return false;
|
|
43
|
+
if (repoRoot(root)) return true;
|
|
44
|
+
// git absent from PATH: fall back to the on-disk marker so we don't call a real repo "not a
|
|
45
|
+
// repo". A normal repo has a `.git` dir; a linked worktree has a `.git` FILE — both count.
|
|
46
|
+
try { return fs.existsSync(path.join(root, '.git')); } catch { return false; }
|
|
47
|
+
}
|
|
48
|
+
|
|
23
49
|
// git check-ref-format for a BRANCH name, re-implemented defensively so nothing dubious ever
|
|
24
50
|
// reaches `git` (defense in depth — `git` itself is the final authority and rejects the rest).
|
|
25
51
|
// Rejects: empty, whitespace/control, the special chars ~ ^ : ? * [ \ , the sequences .. and @{,
|
|
@@ -38,25 +64,42 @@ function validBranchName(name) {
|
|
|
38
64
|
return true;
|
|
39
65
|
}
|
|
40
66
|
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
67
|
+
// Every working tree attached to this repo: [{ dir, branch|null }]. Used for two honest answers —
|
|
68
|
+
// "this branch is already open over there" (git will refuse a second checkout of it, so the picker
|
|
69
|
+
// says so instead of letting the PM hit a raw git error) and "here is where an isolated session
|
|
70
|
+
// would land".
|
|
71
|
+
function worktrees(root) {
|
|
72
|
+
const top = repoRoot(root);
|
|
73
|
+
return top ? worktreesAt(top) : [];
|
|
74
|
+
}
|
|
75
|
+
// same, for a caller that already resolved the top (saves a `git rev-parse` per call)
|
|
76
|
+
function worktreesAt(top) {
|
|
77
|
+
const r = git(top, ['worktree', 'list', '--porcelain']);
|
|
78
|
+
if (r.failed || r.code !== 0 || !r.out) return [];
|
|
79
|
+
const out = [];
|
|
80
|
+
let cur = null;
|
|
81
|
+
for (const line of r.out.split('\n')) {
|
|
82
|
+
if (line.startsWith('worktree ')) { cur = { dir: line.slice(9).trim(), branch: null }; out.push(cur); }
|
|
83
|
+
else if (cur && line.startsWith('branch ')) cur.branch = line.slice(7).trim().replace(/^refs\/heads\//, '');
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
55
86
|
}
|
|
56
87
|
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
88
|
+
// Where an isolated worktree lands: a sibling of the repo, gathered under one `<repo>-worktrees/`
|
|
89
|
+
// folder. Deliberately OUTSIDE the repo — nothing new appears inside the tree, so no `git status`
|
|
90
|
+
// noise and nothing for .gitignore to chase. The path is derived from a name already passed
|
|
91
|
+
// through validBranchName (charset A–Za–z0–9._/-, no `..`, no leading `.`), so the only
|
|
92
|
+
// transformation needed is '/' → '-' to keep it one directory segment.
|
|
93
|
+
function worktreePath(root, branch) {
|
|
94
|
+
const top = repoRoot(root);
|
|
95
|
+
if (!top || !validBranchName(branch)) return null;
|
|
96
|
+
return path.join(path.dirname(top), path.basename(top) + '-worktrees', branch.replace(/\//g, '-'));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Check out an existing branch, or create one with -b. THIS MOVES THE WHOLE REPO — every session
|
|
100
|
+
// in every workspace under it. Confined to `root` by cwd; the name is validated first so nothing
|
|
101
|
+
// malformed reaches git. On any failure returns { ok:false, error } and leaves the caller to NOT
|
|
102
|
+
// spawn a session on the wrong branch (a failed checkout must never spawn).
|
|
60
103
|
function checkout(root, branch, { create = false } = {}) {
|
|
61
104
|
if (!isRepo(root)) return { ok: false, error: 'not a git repository' };
|
|
62
105
|
if (!validBranchName(branch)) return { ok: false, error: 'invalid branch name' };
|
|
@@ -68,4 +111,133 @@ function checkout(root, branch, { create = false } = {}) {
|
|
|
68
111
|
return { ok: true, current: info.ok ? info.current : branch };
|
|
69
112
|
}
|
|
70
113
|
|
|
71
|
-
|
|
114
|
+
// Put `branch` in its OWN working tree, so a session can start on it while every already-running
|
|
115
|
+
// session stays untouched. `create` makes the branch (`-b`); otherwise the branch must exist and
|
|
116
|
+
// must not already be checked out somewhere else (git refuses — we return that plainly).
|
|
117
|
+
// Returns { ok, dir, branch, reused } or { ok:false, error }.
|
|
118
|
+
function addWorktree(root, branch, { create = false } = {}) {
|
|
119
|
+
const top = repoRoot(root);
|
|
120
|
+
if (!top) return { ok: false, error: 'not a git repository' };
|
|
121
|
+
if (!validBranchName(branch)) return { ok: false, error: 'invalid branch name' };
|
|
122
|
+
|
|
123
|
+
// Already checked out somewhere? Say where, rather than letting git's message be the first the
|
|
124
|
+
// PM sees — and if it is exactly the worktree we would have made, reuse it instead of failing.
|
|
125
|
+
const dir = worktreePath(top, branch);
|
|
126
|
+
const existing = worktreesAt(top).find((w) => w.branch === branch);
|
|
127
|
+
if (existing) {
|
|
128
|
+
if (create) return { ok: false, error: `branch '${branch}' already exists (checked out at ${existing.dir})` };
|
|
129
|
+
if (path.resolve(existing.dir) === path.resolve(dir)) return { ok: true, dir, branch, reused: true };
|
|
130
|
+
return { ok: false, error: `branch '${branch}' is already checked out at ${existing.dir} — git allows one working tree per branch` };
|
|
131
|
+
}
|
|
132
|
+
if (fs.existsSync(dir)) return { ok: false, error: `${dir} already exists — move or remove it, or pick another branch name` };
|
|
133
|
+
|
|
134
|
+
try { fs.mkdirSync(path.dirname(dir), { recursive: true }); }
|
|
135
|
+
catch (e) { return { ok: false, error: `cannot create ${path.dirname(dir)}: ${e.code || e.message}` }; }
|
|
136
|
+
|
|
137
|
+
const args = create ? ['worktree', 'add', '-b', branch, dir] : ['worktree', 'add', dir, branch];
|
|
138
|
+
const r = git(top, args);
|
|
139
|
+
if (r.failed) return { ok: false, error: 'git not available' };
|
|
140
|
+
if (r.code !== 0) {
|
|
141
|
+
// never leave a half-made directory behind to block the next attempt
|
|
142
|
+
try { if (fs.existsSync(dir) && fs.readdirSync(dir).length === 0) fs.rmdirSync(dir); } catch {}
|
|
143
|
+
return { ok: false, error: (r.err || r.out || 'worktree add failed').split('\n').filter(Boolean).pop().slice(0, 200) };
|
|
144
|
+
}
|
|
145
|
+
return { ok: true, dir, branch, reused: false };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Everything needed to decide whether a worktree can be removed WITHOUT losing work. Two hazards
|
|
149
|
+
// live in git itself, and each is reported BY NAME so the UI can say why rather than just greying
|
|
150
|
+
// out a button:
|
|
151
|
+
// dirty — uncommitted or untracked files sitting in the tree
|
|
152
|
+
// unique commits — commits on this branch that no other branch, remote or tag holds, so removing
|
|
153
|
+
// the tree is the last thing standing between them and `git gc`
|
|
154
|
+
// Anything unreadable counts as unsafe: this gates a delete, so "I could not check" must never
|
|
155
|
+
// read as "fine". (The caller adds the two hazards git cannot see: live sessions in the tree, and
|
|
156
|
+
// "this is the repo's main worktree".)
|
|
157
|
+
function worktreeSafety(dir, branch) {
|
|
158
|
+
const reasons = [];
|
|
159
|
+
const st = git(dir, ['status', '--porcelain']);
|
|
160
|
+
const dirty = st.failed || st.code !== 0 ? true : st.out.length > 0;
|
|
161
|
+
if (dirty) reasons.push(st.code === 0 ? 'uncommitted changes' : 'its state could not be read');
|
|
162
|
+
|
|
163
|
+
let unique = 0;
|
|
164
|
+
if (!branch) {
|
|
165
|
+
unique = -1;
|
|
166
|
+
reasons.push('detached HEAD — no branch names its commits');
|
|
167
|
+
} else {
|
|
168
|
+
// "commits reachable from this branch and from nothing else". --exclude applies to the NEXT
|
|
169
|
+
// ref-listing option only, so --branches drops this branch while --remotes and --tags still
|
|
170
|
+
// count: a copy on a remote means the commits survive the removal.
|
|
171
|
+
const rl = git(dir, ['rev-list', '--count', 'refs/heads/' + branch,
|
|
172
|
+
'--not', '--exclude=' + branch, '--branches', '--remotes', '--tags']);
|
|
173
|
+
unique = rl.code === 0 && /^\d+$/.test(rl.out) ? parseInt(rl.out, 10) : -1;
|
|
174
|
+
if (unique > 0) reasons.push(`${unique} commit${unique === 1 ? '' : 's'} not on any other branch or remote`);
|
|
175
|
+
if (unique < 0) reasons.push('could not verify its commits exist elsewhere');
|
|
176
|
+
}
|
|
177
|
+
return { removable: reasons.length === 0, reasons, dirty, unique };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Remove a linked worktree, refusing anything that would lose work. `dir` arrives from a client, so
|
|
181
|
+
// it is never passed through as given: it must match one of THIS repo's linked worktrees (git's own
|
|
182
|
+
// list is the allowlist), and the main worktree is unreachable by construction. The BRANCH is left
|
|
183
|
+
// alone — a branch is a cheap pointer, and deleting one is a different decision than reclaiming a
|
|
184
|
+
// directory.
|
|
185
|
+
function removeWorktree(root, dir) {
|
|
186
|
+
const top = repoRoot(root);
|
|
187
|
+
if (!top) return { ok: false, error: 'not a git repository' };
|
|
188
|
+
const trees = worktreesAt(top);
|
|
189
|
+
// index 0 is always the main worktree — sliced off, so it can never be the target
|
|
190
|
+
const target = trees.slice(1).find((w) => path.resolve(w.dir) === path.resolve(String(dir || '')));
|
|
191
|
+
if (!target) return { ok: false, error: 'not a linked worktree of this repository' };
|
|
192
|
+
|
|
193
|
+
// Gone from disk already (the PM deleted it by hand): there is nothing to protect, just stale
|
|
194
|
+
// bookkeeping to clear.
|
|
195
|
+
if (!isDir(target.dir)) {
|
|
196
|
+
const p = git(top, ['worktree', 'prune']);
|
|
197
|
+
if (p.code !== 0) return { ok: false, error: (p.err || 'prune failed').split('\n')[0].slice(0, 200) };
|
|
198
|
+
return { ok: true, dir: target.dir, pruned: true };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const safety = worktreeSafety(target.dir, target.branch);
|
|
202
|
+
if (!safety.removable) return { ok: false, error: 'kept — ' + safety.reasons.join('; '), safety };
|
|
203
|
+
const r = git(top, ['worktree', 'remove', target.dir]);
|
|
204
|
+
if (r.failed) return { ok: false, error: 'git not available' };
|
|
205
|
+
if (r.code !== 0) return { ok: false, error: (r.err || r.out || 'worktree remove failed').split('\n').filter(Boolean).pop().slice(0, 200) };
|
|
206
|
+
return { ok: true, dir: target.dir, branch: target.branch, pruned: false };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// { ok, current, branches[], detached, dirty, repoRoot, subdir, linked, worktrees[] }. A non-repo
|
|
210
|
+
// or a failed git returns ok:false with a reason — NEVER a fabricated branch (a wrong branch chip
|
|
211
|
+
// is worse than none; anti-drift). `subdir` is the workspace's path INSIDE the repo ('' at the
|
|
212
|
+
// top): non-empty means a checkout here moves a tree larger than this workspace, which the UI says
|
|
213
|
+
// out loud.
|
|
214
|
+
function branchInfo(root) {
|
|
215
|
+
// `code` lets a caller tell the two failures apart without matching on prose: `no-git` is a
|
|
216
|
+
// perfectly normal folder (a tenant product repo with no git at all) and deserves no alarm;
|
|
217
|
+
// `git-error` is something the PM may want to look at.
|
|
218
|
+
const top = repoRoot(root);
|
|
219
|
+
if (!top) {
|
|
220
|
+
if (!isRepo(root)) return { ok: false, code: 'no-git', error: 'not a git repository' };
|
|
221
|
+
return { ok: false, code: 'git-error', error: 'git not available' };
|
|
222
|
+
}
|
|
223
|
+
const head = git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
224
|
+
if (head.failed) return { ok: false, code: 'git-error', error: 'git not available' };
|
|
225
|
+
if (head.code !== 0) return { ok: false, code: 'git-error', error: head.err || 'could not read HEAD' };
|
|
226
|
+
const detached = head.out === 'HEAD';
|
|
227
|
+
const current = detached ? null : head.out;
|
|
228
|
+
const refs = git(root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']);
|
|
229
|
+
const branches = refs.code === 0 && refs.out ? refs.out.split('\n').filter(Boolean) : [];
|
|
230
|
+
const status = git(root, ['status', '--porcelain']);
|
|
231
|
+
const dirty = status.code === 0 ? status.out.length > 0 : false;
|
|
232
|
+
const rel = path.relative(top, path.resolve(root));
|
|
233
|
+
const trees = worktreesAt(top);
|
|
234
|
+
const linked = path.resolve(top) !== path.resolve(trees[0] ? trees[0].dir : top);
|
|
235
|
+
return {
|
|
236
|
+
ok: true, current, branches, detached, dirty,
|
|
237
|
+
repoRoot: top, subdir: rel === '' || rel === '.' ? '' : rel, linked,
|
|
238
|
+
worktrees: trees.map((w) => ({ dir: w.dir, branch: w.branch })),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
module.exports = { isRepo, repoRoot, validBranchName, branchInfo, checkout, worktrees, worktreePath,
|
|
243
|
+
addWorktree, worktreeSafety, removeWorktree };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/* related-docs — which OTHER documents does this change put out of step?
|
|
2
|
+
* (workspace-doc-editor · R5/R6)
|
|
3
|
+
*
|
|
4
|
+
* Pure: takes the changed file, what changed in it, and a list of {path, text} for the repository's
|
|
5
|
+
* other documents. Returns candidates with a stated reason. No filesystem, no network, so the
|
|
6
|
+
* scoring rules are unit-testable directly and the endpoint stays a thin caller.
|
|
7
|
+
*
|
|
8
|
+
* R6 is the constraint that shapes everything here: **alignment is scoped by the change, not by the
|
|
9
|
+
* repository.** A pass that re-reads everything is a review by another name — the exact feature this
|
|
10
|
+
* one replaced, arriving through the back door. So a document only becomes a candidate if it has a
|
|
11
|
+
* concrete tie to the thing that changed:
|
|
12
|
+
*
|
|
13
|
+
* - it LINKS to the changed file, or names it in frontmatter (prd_path, rubric_path, …)
|
|
14
|
+
* - it shares the changed file's initiative_id
|
|
15
|
+
* - it QUOTES text the change removed or altered
|
|
16
|
+
*
|
|
17
|
+
* "Feels related" is deliberately not a rule. Semantic similarity would widen this to everything and
|
|
18
|
+
* put the PM back in front of a queue of an agent's opinions, which is what they said they did not
|
|
19
|
+
* want. If references miss something, that is a known and accepted cost, recorded in the PRD.
|
|
20
|
+
*/
|
|
21
|
+
'use strict';
|
|
22
|
+
(function (root, factory) {
|
|
23
|
+
const api = factory();
|
|
24
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
25
|
+
else root.RelatedDocs = api;
|
|
26
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
27
|
+
|
|
28
|
+
const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
29
|
+
|
|
30
|
+
/** `key: value` from a leading frontmatter block. Tolerates a missing block. */
|
|
31
|
+
function frontmatter(text) {
|
|
32
|
+
const m = /^---\n([\s\S]*?)\n---/.exec(String(text || ''));
|
|
33
|
+
if (!m) return {};
|
|
34
|
+
const out = {};
|
|
35
|
+
for (const line of m[1].split('\n')) {
|
|
36
|
+
const i = line.indexOf(':');
|
|
37
|
+
if (i < 1) continue;
|
|
38
|
+
out[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^["']|["']$/g, '');
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* Phrases worth searching other documents for. Short or generic lines match everything and would
|
|
44
|
+
* turn precision into noise, so a line must be substantial to qualify — and only a few are used,
|
|
45
|
+
* because each one costs a scan of every document. */
|
|
46
|
+
function phrasesFrom(removedText, min = 24, max = 4) {
|
|
47
|
+
return String(removedText || '')
|
|
48
|
+
.split('\n')
|
|
49
|
+
.map((l) => l.replace(/^[\s>#*\-+\d.]+/, '').trim())
|
|
50
|
+
.filter((l) => l.length >= min && /[a-z]{4}/i.test(l))
|
|
51
|
+
.sort((a, b) => b.length - a.length)
|
|
52
|
+
.slice(0, max);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* candidates({ changedPath, changedText, removed, docs })
|
|
57
|
+
* changedPath — repo-relative path of the document just saved
|
|
58
|
+
* changedText — its full text after the save (for its own frontmatter)
|
|
59
|
+
* removed — the text the save removed or replaced (drives the quote rule)
|
|
60
|
+
* docs — [{ path, text }] for the repository's other markdown documents
|
|
61
|
+
* → [{ path, reasons: [string], strength }] sorted strongest first. Never throws.
|
|
62
|
+
*/
|
|
63
|
+
function candidates({ changedPath, changedText, removed, docs } = {}) {
|
|
64
|
+
const target = String(changedPath || '');
|
|
65
|
+
if (!target || !Array.isArray(docs)) return [];
|
|
66
|
+
const base = target.split('/').pop();
|
|
67
|
+
const mine = frontmatter(changedText);
|
|
68
|
+
const initiative = mine.initiative_id || mine.id || null;
|
|
69
|
+
const phrases = phrasesFrom(removed);
|
|
70
|
+
const out = [];
|
|
71
|
+
|
|
72
|
+
for (const d of docs) {
|
|
73
|
+
if (!d || typeof d.path !== 'string' || d.path === target) continue;
|
|
74
|
+
const text = String(d.text || '');
|
|
75
|
+
const reasons = [];
|
|
76
|
+
|
|
77
|
+
// 1. links to the changed file — by full repo path or by filename
|
|
78
|
+
if (text.includes(target) || new RegExp('\\(([^)]*/)?' + esc(base) + '\\)').test(text)) {
|
|
79
|
+
reasons.push('links to this document');
|
|
80
|
+
}
|
|
81
|
+
// 2. frontmatter points at it (prd_path, rubric_path, and anything else path-shaped)
|
|
82
|
+
const fm = frontmatter(text);
|
|
83
|
+
for (const [k, v] of Object.entries(fm)) {
|
|
84
|
+
if (typeof v === 'string' && v && (v === target || v.endsWith('/' + base))) {
|
|
85
|
+
reasons.push(`its ${k} is this document`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// 3. same initiative — the artifacts of one piece of work move together
|
|
89
|
+
if (initiative && (fm.initiative_id === initiative || fm.id === initiative)) {
|
|
90
|
+
reasons.push(`same initiative (${initiative})`);
|
|
91
|
+
}
|
|
92
|
+
// 4. quotes text this change removed or altered — the strongest signal there is, because the
|
|
93
|
+
// other document is now asserting something the source no longer says
|
|
94
|
+
for (const ph of phrases) {
|
|
95
|
+
if (text.includes(ph)) { reasons.push(`quotes text this change altered: "${ph.slice(0, 60)}…"`); break; }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (reasons.length) {
|
|
99
|
+
const quotes = reasons.some((r) => r.startsWith('quotes text'));
|
|
100
|
+
out.push({ path: d.path, reasons, strength: reasons.length + (quotes ? 2 : 0) });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out.sort((a, b) => b.strength - a.strength || a.path.localeCompare(b.path));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { candidates, frontmatter, phrasesFrom };
|
|
107
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/* session-state — the presented state of a session, joined from the workspace's TWO sources.
|
|
2
|
+
* (workspace-waiting-on-you R2/R3/R4/R5/R6.)
|
|
3
|
+
*
|
|
4
|
+
* One source of truth for "what word goes beside this session", used on BOTH sides:
|
|
5
|
+
* - server.js requires it (sessionList publishes the joined state)
|
|
6
|
+
* - the browser loads it as /lib/session-state.js before app.js (window.PMOSSessionState)
|
|
7
|
+
* so the sidebar and the server can never hold different opinions about one session. That is not
|
|
8
|
+
* hypothetical tidiness: QUIET_AFTER_MS was once re-typed as a literal in app.js, two copies of one
|
|
9
|
+
* number free to drift, and the fix was to ship it from one place. A duplicated TABLE fails the
|
|
10
|
+
* same way with more surface area.
|
|
11
|
+
*
|
|
12
|
+
* ── THE TWO SOURCES, AND WHY NEITHER IS ENOUGH ALONE ────────────────────────────────────────
|
|
13
|
+
* CADENCE (harness-parse.deriveState ← lastOutputAt) answers "is anything happening?"
|
|
14
|
+
* STRUCTURE (transcript-read.turn ← the JSONL) answers "whose turn is it?"
|
|
15
|
+
*
|
|
16
|
+
* Cadence alone cannot tell a finished turn from a crash from a slow tool — which is why
|
|
17
|
+
* workspace-harness-tower's R1 forbade upgrading `quiet` to "waiting on you", and why that rule is
|
|
18
|
+
* SATISFIED here rather than repealed: the stronger words are built from BOTH, and every cell
|
|
19
|
+
* without corroboration still says exactly what it said before.
|
|
20
|
+
*
|
|
21
|
+
* The two contributions are NOT symmetric, and pretending otherwise would be the first step toward
|
|
22
|
+
* over-claiming:
|
|
23
|
+
* - for `needs` the transcript is load-bearing and the quiet clock is only a SETTLE-TIMER
|
|
24
|
+
* (the terminal is still painting the final block for a moment after a turn ends)
|
|
25
|
+
* - for `blocked` the transcript only says a call is open; the quiet clock does the real
|
|
26
|
+
* discriminating, because a running tool emits bytes and a permission prompt does not
|
|
27
|
+
*
|
|
28
|
+
* ── THE PROPERTY THIS FILE EXISTS TO GUARANTEE (R4) ─────────────────────────────────────────
|
|
29
|
+
* `needs` is reachable ONLY from turn === 'user', and transcript-read sets 'user' only when NO
|
|
30
|
+
* tool_use is unresolved. A crashed session's dangling call is a permanent phantom (2/25 real
|
|
31
|
+
* transcripts) and a phantom IS an unresolved call — so a phantom can only ever reach `tool`, and
|
|
32
|
+
* `needs` is UNCONSTRUCTABLE for it.
|
|
33
|
+
*
|
|
34
|
+
* There is deliberately NO `if (!phantom)` anywhere here. A check is a thing a later change can
|
|
35
|
+
* forget to call; a state that cannot be built needs no discipline to stay honest. This is the same
|
|
36
|
+
* discipline as the shipped "age without liveness is refused" — and if you are editing this file,
|
|
37
|
+
* the test that matters is `needs you` over the join's WHOLE domain, not one example of it.
|
|
38
|
+
*/
|
|
39
|
+
'use strict';
|
|
40
|
+
(function (root, factory) {
|
|
41
|
+
const api = factory();
|
|
42
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
43
|
+
else root.PMOSSessionState = api;
|
|
44
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
45
|
+
|
|
46
|
+
// The cadence clock's three values (harness-parse.deriveState) — this module's first input.
|
|
47
|
+
const CADENCE = ['working', 'quiet', 'exited'];
|
|
48
|
+
// The transcript's answer to whose turn it is — this module's second input. `null` = no answer,
|
|
49
|
+
// which is the NORMAL case for a shell/codex session and for any claude session whose transcript
|
|
50
|
+
// cannot be resolved. It is not an error and draws no apology.
|
|
51
|
+
const TURN = ['user', 'agent', 'tool', null];
|
|
52
|
+
// What can end up on screen. The first three are pre-existing and unchanged; the last two are
|
|
53
|
+
// this feature. `failed` is absent by design — it has no observable (harness-tower).
|
|
54
|
+
const PRESENTED = ['working', 'quiet', 'exited', 'needs', 'blocked'];
|
|
55
|
+
|
|
56
|
+
// The word beside the glyph. Deliberately separate from the key: `blocked`'s word names its own
|
|
57
|
+
// uncertainty, because its evidence cannot distinguish a permission prompt from a hung tool from
|
|
58
|
+
// a corpse. The surface may not launder that into confidence.
|
|
59
|
+
const WORD = {
|
|
60
|
+
working: 'working',
|
|
61
|
+
quiet: 'quiet',
|
|
62
|
+
exited: 'exited',
|
|
63
|
+
needs: 'needs you',
|
|
64
|
+
blocked: 'maybe blocked',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Filled = corroborated by both sources, hollow = suspected from one. `needs` takes ◆, which has
|
|
68
|
+
// sat declared-but-dead in the client's GLYPH map since the harness tower; this makes it live.
|
|
69
|
+
const GLYPH = { working: '●', quiet: '◼', exited: '○', needs: '◆', blocked: '◇' };
|
|
70
|
+
|
|
71
|
+
// Which presented states may carry the open call's verb + age. Unchanged for `working` (shipped
|
|
72
|
+
// behaviour); extended to `blocked`, where the age is the most informative part of a weak claim —
|
|
73
|
+
// "a call has been open 6m and nothing is moving" is the whole signal.
|
|
74
|
+
const CARRIES_CALL_AGE = { working: true, blocked: true };
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* THE JOIN (R2). Total over CADENCE × TURN: every one of the twelve cells is defined here, and
|
|
78
|
+
* `presentedStates()` below exists so a test can walk the domain rather than sample it.
|
|
79
|
+
*
|
|
80
|
+
* Pure: no clock read, no I/O, no DOM. The caller supplies the already-derived cadence state, so
|
|
81
|
+
* this function cannot disagree with `deriveState` about the threshold — it never sees one.
|
|
82
|
+
*
|
|
83
|
+
* turn ↓ / cadence → working quiet exited
|
|
84
|
+
* 'user' working NEEDS exited
|
|
85
|
+
* 'tool' working BLOCKED exited
|
|
86
|
+
* 'agent' working quiet exited
|
|
87
|
+
* null working quiet exited
|
|
88
|
+
*
|
|
89
|
+
* Three cells change from pre-feature behaviour. The other nine are byte-identical, and that is
|
|
90
|
+
* asserted rather than assumed.
|
|
91
|
+
*/
|
|
92
|
+
function presented(cadence, turn) {
|
|
93
|
+
// `exited` is the server's own lifecycle fact and outranks anything the transcript says: a
|
|
94
|
+
// transcript is a file that outlives its process, so a dead session's last record would
|
|
95
|
+
// otherwise keep claiming a turn forever.
|
|
96
|
+
if (cadence === 'exited') return 'exited';
|
|
97
|
+
// Bytes are arriving. Whatever the transcript says, something IS happening, so no stronger
|
|
98
|
+
// claim is available or needed — and this is what stops `needs` firing during the moment the
|
|
99
|
+
// terminal is still painting the turn it just finished.
|
|
100
|
+
if (cadence === 'working') return 'working';
|
|
101
|
+
if (cadence !== 'quiet') return 'working'; // unknown cadence ⇒ the weakest live word, never a claim
|
|
102
|
+
// Quiet. Only here can the transcript raise the claim above `quiet`.
|
|
103
|
+
if (turn === 'user') return 'needs'; // turn ended AND nothing moving ⇒ the human is up
|
|
104
|
+
if (turn === 'tool') return 'blocked'; // a call is open AND nothing moving ⇒ suspect
|
|
105
|
+
return 'quiet'; // 'agent' (model call in flight) or null (no source)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Every (cadence, turn, presented) triple. The domain is small — walk it, don't sample it. */
|
|
109
|
+
function presentedStates() {
|
|
110
|
+
const out = [];
|
|
111
|
+
for (const cadence of CADENCE) {
|
|
112
|
+
for (const turn of TURN) out.push({ cadence, turn, presented: presented(cadence, turn) });
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Does this presented state make the strong "this session wants you" claim? */
|
|
118
|
+
function isNeedsClaim(p) { return p === 'needs'; }
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The full label for a row or tab: the word, plus the quiet clock's age on `quiet`, plus the open
|
|
122
|
+
* call's verb and age where the state carries it. `fmtDur` is injected (the client and the server
|
|
123
|
+
* format durations for different audiences) and the ages are computed by the CALLER, because this
|
|
124
|
+
* module reads no clock.
|
|
125
|
+
*/
|
|
126
|
+
function label(p, { quietMs = null, call = null, fmtDur = null } = {}) {
|
|
127
|
+
const w = WORD[p] || p;
|
|
128
|
+
const d = (ms) => (fmtDur && ms != null ? fmtDur(ms) : null);
|
|
129
|
+
if (p === 'quiet') { const s = d(quietMs); return s ? `${w} ${s}` : w; }
|
|
130
|
+
if (CARRIES_CALL_AGE[p] && call && call.verb) {
|
|
131
|
+
const s = d(call.ageMs);
|
|
132
|
+
return s ? `${w} · ${call.verb} ${s}` : `${w} · ${call.verb}`;
|
|
133
|
+
}
|
|
134
|
+
return w;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { CADENCE, TURN, PRESENTED, WORD, GLYPH, CARRIES_CALL_AGE,
|
|
138
|
+
presented, presentedStates, isNeedsClaim, label };
|
|
139
|
+
});
|
|
@@ -168,7 +168,7 @@ function prettyTool(name) {
|
|
|
168
168
|
*/
|
|
169
169
|
function readHarness(file, clean = (x) => x) {
|
|
170
170
|
const out = { model: null, effort: null, turns: 0, tokens: null, tools: null, skills: null,
|
|
171
|
-
title: null, openCall: null, missing: {} };
|
|
171
|
+
title: null, openCall: null, turn: null, missing: {} };
|
|
172
172
|
let raw;
|
|
173
173
|
try {
|
|
174
174
|
const st = fs.statSync(file);
|
|
@@ -199,6 +199,11 @@ function readHarness(file, clean = (x) => x) {
|
|
|
199
199
|
// report the raw fact {verb, sinceTs}; we never claim it is live.
|
|
200
200
|
const openUse = new Map(); // tool_use_id → { verb, ts } (insertion order = chronological)
|
|
201
201
|
const resolved = new Set(); // tool_use_ids that got a tool_result
|
|
202
|
+
// WHOSE TURN IS IT (workspace-waiting-on-you R1). The last TURN-TAKING record's role — nothing
|
|
203
|
+
// more. `ai-title` and `attachment` records are CLI bookkeeping and are deliberately not
|
|
204
|
+
// turn-taking, and neither is a SYNTHETIC assistant record: counting one as "the assistant
|
|
205
|
+
// finished speaking" is exactly how a false `needs you` would be manufactured.
|
|
206
|
+
let lastRole = null; // 'assistant' | 'user' | null
|
|
202
207
|
|
|
203
208
|
for (const line of raw.split('\n')) {
|
|
204
209
|
if (!line) continue;
|
|
@@ -217,6 +222,7 @@ function readHarness(file, clean = (x) => x) {
|
|
|
217
222
|
for (const c of (Array.isArray(um.content) ? um.content : [])) {
|
|
218
223
|
if (c && c.type === 'tool_result' && c.tool_use_id) resolved.add(c.tool_use_id);
|
|
219
224
|
}
|
|
225
|
+
lastRole = 'user'; // a prompt was submitted, or a tool result landed
|
|
220
226
|
continue;
|
|
221
227
|
}
|
|
222
228
|
if (r.type === 'attachment') {
|
|
@@ -234,6 +240,7 @@ function readHarness(file, clean = (x) => x) {
|
|
|
234
240
|
if (m.model && m.model !== SYNTHETIC) {
|
|
235
241
|
out.model = clean(m.model);
|
|
236
242
|
out.turns++; // only real model turns count
|
|
243
|
+
lastRole = 'assistant'; // ...and only a real turn takes the turn
|
|
237
244
|
if (r.effort) out.effort = clean(String(r.effort));
|
|
238
245
|
}
|
|
239
246
|
const u = m.usage || {};
|
|
@@ -260,6 +267,19 @@ function readHarness(file, clean = (x) => x) {
|
|
|
260
267
|
if (!resolved.has(id)) out.openCall = { verb: info.verb, sinceTs: info.ts };
|
|
261
268
|
}
|
|
262
269
|
|
|
270
|
+
// ── Whose turn (R1). Derived here, from what the pass already collected — no second walk. ──
|
|
271
|
+
// The ORDER of these three branches is the honesty property the whole feature rests on (R4).
|
|
272
|
+
// `tool` is tested FIRST, so any unresolved call claims the turn. A crashed session's dangling
|
|
273
|
+
// tool_use is a PERMANENT phantom (2/25 real transcripts), and a phantom is by definition an
|
|
274
|
+
// unresolved call — so a phantom always lands on `tool`, and `user` (the only turn from which
|
|
275
|
+
// the strong `needs you` claim can be built) is UNREACHABLE for it. Not a guard that rejects
|
|
276
|
+
// the phantom: a value it cannot construct. Reordering these branches silently converts a
|
|
277
|
+
// corpse into "waiting on you", which is the one lie this surface must not tell.
|
|
278
|
+
if (out.openCall) out.turn = 'tool'; // awaiting a tool — OR the PM's approval of one
|
|
279
|
+
else if (lastRole === 'assistant') out.turn = 'user'; // the model stopped; the human is up
|
|
280
|
+
else if (lastRole === 'user') out.turn = 'agent'; // a prompt/result landed; the model is up
|
|
281
|
+
// else: null — nothing turn-taking was parseable, so no claim is made.
|
|
282
|
+
|
|
263
283
|
if (!out.model) out.missing.model = 'the transcript names no model yet';
|
|
264
284
|
if (!out.effort) out.missing.effort = 'the transcript records no effort level';
|
|
265
285
|
if (toolTotal) out.tools = { counts: toolCounts, total: toolTotal };
|
|
@@ -271,11 +291,13 @@ function readHarness(file, clean = (x) => x) {
|
|
|
271
291
|
else out.missing.tokens = 'the transcript records no token usage yet';
|
|
272
292
|
if (!out.skills) out.missing.skills = 'the CLI listed no skills for this session';
|
|
273
293
|
if (!out.turns) out.missing.turns = 'no model turns in the transcript yet';
|
|
274
|
-
// NOTE: `title` and `
|
|
275
|
-
// UI RENDERS in place of a value;
|
|
276
|
-
// (agent · cwd for an untitled session; the plain `working`/`quiet` word when no call
|
|
277
|
-
// Putting them here would break the map's contract —
|
|
278
|
-
// and put a reason on screen where a perfectly good
|
|
294
|
+
// NOTE: `title`, `openCall` and `turn` deliberately get NO `missing` entry. This map carries
|
|
295
|
+
// reasons the UI RENDERS in place of a value; all three instead fall back to a real shipped
|
|
296
|
+
// rendering (agent · cwd for an untitled session; the plain `working`/`quiet` word when no call
|
|
297
|
+
// is open and when the turn is unknown). Putting them here would break the map's contract —
|
|
298
|
+
// "empty missing == everything was readable" — and put a reason on screen where a perfectly good
|
|
299
|
+
// value already goes. For `turn` that fallback IS the feature's refusal rule (R5): no second
|
|
300
|
+
// source, no claim, and the row reads exactly as it did before this existed.
|
|
279
301
|
|
|
280
302
|
return out;
|
|
281
303
|
}
|