create-pmos 0.26.0 → 0.28.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 +26 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/workspace/README.md +39 -7
- package/workspace/bin/pmos.js +5 -1
- package/workspace/doctor.js +5 -1
- package/workspace/lib/drive-guard.js +165 -30
- package/workspace/lib/skill-store.js +180 -6
- package/workspace/lib/state-root.js +587 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +2 -2
- package/workspace/public/pm/data.js +80 -0
- package/workspace/public/pm/panel-initiatives.js +169 -4
- package/workspace/public/pm/panel-newsess.js +507 -0
- package/workspace/public/pm/panel-repos.js +31 -0
- package/workspace/public/pm/panel-tabs.js +34 -2
- package/workspace/public/pm/state.js +40 -1
- package/workspace/public/pm/tab-term.js +277 -16
- package/workspace/server.js +370 -26
- package/workspace/test-run.js +84 -0
package/workspace/server.js
CHANGED
|
@@ -111,7 +111,96 @@ const HOME_ENABLED = EMBEDDED_SELF || (!!process.env.PMOS_SELF_ROOT && dirExists
|
|
|
111
111
|
const PUB = path.join(__dirname, 'public');
|
|
112
112
|
const PM_DIR = path.join(PUB, 'pm'); // the four-panel front end — served as a directory, guarded below
|
|
113
113
|
const XROOT = path.join(__dirname, 'node_modules', '@xterm');
|
|
114
|
-
|
|
114
|
+
// ── WHERE THE PM'S RUNTIME STATE LIVES (see lib/state-root.js for the whole argument). ─────────
|
|
115
|
+
// It is NOT `workspace/state/` any more. That directory is inside the application directory the
|
|
116
|
+
// test suite runs from, which put the PM's live sessions, workspace registry and authored skills
|
|
117
|
+
// within reach of every test, every spawned child and every stray shell command — and three rounds
|
|
118
|
+
// of guarding destructive verbs failed, each to a verb nobody had guarded yet. Unreachable beats
|
|
119
|
+
// guarded. STATE now resolves to a per-user location (PMOS_STATE_DIR wins; else the OS default),
|
|
120
|
+
// and an existing in-repo state/ is MOVED there once, idempotently, on first run.
|
|
121
|
+
const stateRoot = require('./lib/state-root');
|
|
122
|
+
// LEGACY_STATE is what `workspace/state/` used to be, kept for exactly two jobs: it is the source
|
|
123
|
+
// the migration moves FROM, and it is D64's file-mode mirror for an EMBEDDED PMOS-self checkout
|
|
124
|
+
// that has no `pmos/state/` yet (see stateDirFor). The system of record stays in the repository
|
|
125
|
+
// that earned it — only the churny runtime moved out.
|
|
126
|
+
const LEGACY_STATE = path.join(__dirname, 'state');
|
|
127
|
+
// ── REQUIRING THIS FILE IS NOT A QUIET ACT. ───────────────────────────────────────────────────
|
|
128
|
+
// `require('./server.js')` — which two suites do, for the exported helpers — runs everything at
|
|
129
|
+
// module scope: it resolves the state root, registers the home workspace, restores persisted
|
|
130
|
+
// sessions and spawns a first shell. Unconfigured, all of that now lands in the PM's REAL per-user
|
|
131
|
+
// state, and it did exactly that once while this change was being written. So a library consumer
|
|
132
|
+
// must name its own state directory, and finds out by being told rather than by contaminating the
|
|
133
|
+
// PM's registry and discovering it a day later.
|
|
134
|
+
const REQUIRED_AS_LIBRARY = require.main !== module;
|
|
135
|
+
if (REQUIRED_AS_LIBRARY && !stateRoot.overrideDir(process.env)) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
'server.js was required as a library with no PMOS_STATE_DIR set. Requiring it runs its module ' +
|
|
138
|
+
'scope — resolving the runtime state root, registering the home workspace and spawning a shell ' +
|
|
139
|
+
'— which would write into the PM\'s real runtime state. Point PMOS_STATE_DIR at a scratch ' +
|
|
140
|
+
'directory before the require (test-run.js#configure does this).');
|
|
141
|
+
}
|
|
142
|
+
// The migration is likewise offered ONLY to a real server start. Relocating the PM's live sessions
|
|
143
|
+
// as a side effect of an import is not a thing an import may do, whatever its state directory.
|
|
144
|
+
const STATE_RESOLVED = stateRoot.resolveStateRoot({
|
|
145
|
+
env: process.env,
|
|
146
|
+
legacy: REQUIRED_AS_LIBRARY ? null : LEGACY_STATE,
|
|
147
|
+
});
|
|
148
|
+
const STATE = STATE_RESOLVED.root;
|
|
149
|
+
// ── AN UNSETTLED STATE ROOT MUST NOT DEGRADE INTO "YOU HAVE NOTHING". ─────────────────────────
|
|
150
|
+
// When both directories hold state nothing is moved (only a human can decide which sixteen
|
|
151
|
+
// sessions are the real sixteen) — but the stores the per-user root does NOT hold used to resolve
|
|
152
|
+
// to it anyway, so the PM's eleven skills and forty-five schedules rendered as two empty lists and
|
|
153
|
+
// forty-five scheduled runs silently stopped firing. Per store: fall back to the legacy root ONLY
|
|
154
|
+
// where the per-user root holds no copy at all, so there is exactly one copy and reading/writing it
|
|
155
|
+
// cannot fork anything. See lib/state-root.js#resolveStore for the whole argument, including why a
|
|
156
|
+
// test run can never reach this path.
|
|
157
|
+
//
|
|
158
|
+
// This applies to EVERY outcome that is not a completed move, not only to a conflict. A move the
|
|
159
|
+
// filesystem REFUSES leaves the PM's stores in the legacy directory just as a conflict does, and
|
|
160
|
+
// for one round it resolved them to the per-user root anyway and said nothing at all — the same
|
|
161
|
+
// empty list with the stderr line removed as well. state-root.js#isSettled is the single predicate.
|
|
162
|
+
//
|
|
163
|
+
// RESOLVED PER CALL, NOT ONCE AT BOOT. The previous round pinned these to constants, which meant
|
|
164
|
+
// the process kept whatever it decided at startup: a PM who resolved the conflict — or fixed the
|
|
165
|
+
// permission — went on being served the old directory until they restarted, and a PM who moved a
|
|
166
|
+
// store INTO the per-user root had two live copies with the server writing the stale one. When the
|
|
167
|
+
// root is settled these functions short-circuit before touching the filesystem (the case every test
|
|
168
|
+
// run and every normal boot is in), so the cost is a path.join; only an unsettled root pays for two
|
|
169
|
+
// existsSync calls, which is what the next boot would do anyway.
|
|
170
|
+
function schedStore() {
|
|
171
|
+
return stateRoot.resolveStore({
|
|
172
|
+
root: STATE, legacy: LEGACY_STATE, name: 'schedules.json', migration: STATE_RESOLVED.migration });
|
|
173
|
+
}
|
|
174
|
+
function skillsStore() {
|
|
175
|
+
return stateRoot.resolveStore({
|
|
176
|
+
root: STATE, legacy: LEGACY_STATE, name: 'skills', migration: STATE_RESOLVED.migration });
|
|
177
|
+
}
|
|
178
|
+
// The ONE fact every surface repeats. `{ok:true}` normally; otherwise a discriminated failure naming
|
|
179
|
+
// BOTH directories — and, for a refused move, the errno and the plain-language cause, because that
|
|
180
|
+
// is the one kind the PM cannot resolve from the directory names alone. Carried on /api/workspaces,
|
|
181
|
+
// /api/skills, /api/schedules and every /api/console payload, and written into the first line of
|
|
182
|
+
// every new terminal pane. stderr alone was not a surface: `pmos` detaches it into a log nobody reads.
|
|
183
|
+
function stateHealthNow() {
|
|
184
|
+
const sk = skillsStore(), sc = schedStore();
|
|
185
|
+
return stateRoot.stateHealth({
|
|
186
|
+
migration: STATE_RESOLVED.migration,
|
|
187
|
+
fellBack: [sk.fellBack ? 'skills' : null, sc.fellBack ? 'schedules.json' : null].filter(Boolean),
|
|
188
|
+
// If the state root itself could not be created there is nowhere to read from, every list
|
|
189
|
+
// renders empty and the migration record says nothing about it — resolveStateRoot used to
|
|
190
|
+
// swallow that mkdir on the promise that "the caller's first write will report it". The caller
|
|
191
|
+
// is a store, and what a store does is return []. So the error is carried here instead.
|
|
192
|
+
rootError: STATE_RESOLVED.rootError || null,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
if (STATE_RESOLVED.migration.action === 'moved') {
|
|
196
|
+
console.log(`· moved the workspace's runtime state out of the app directory → ${STATE}`);
|
|
197
|
+
}
|
|
198
|
+
// NOT an `else if`. Chaining it off the 'moved' branch is how 'failed' came to print nothing at all:
|
|
199
|
+
// every non-ok health is reported here on its own terms, whatever the action was.
|
|
200
|
+
{
|
|
201
|
+
const boot = stateHealthNow();
|
|
202
|
+
if (!boot.ok) console.error('✗ ' + boot.error);
|
|
203
|
+
}
|
|
115
204
|
const SESS_DIR = path.join(STATE, 'sessions');
|
|
116
205
|
const SCRATCH_DIR = path.join(STATE, 'scratchpad'); // per-workspace staging text (D62; local-only, gitignored)
|
|
117
206
|
const LAYOUT_DIR = path.join(STATE, 'layouts'); // per-workspace arrangements (R7; local-only, gitignored)
|
|
@@ -402,6 +491,20 @@ function treePeersDescribed(tree, excludeId) {
|
|
|
402
491
|
function treeWriter(tree) {
|
|
403
492
|
return treeShare.writerOf(treeClaims, treeOfWsMap(), [...sessions.values()], tree);
|
|
404
493
|
}
|
|
494
|
+
/**
|
|
495
|
+
* THE UNMIGRATED STATE, SAID WHERE THE PM IS LOOKING.
|
|
496
|
+
*
|
|
497
|
+
* A conflict already printed to stderr — and `pmos` detaches stderr into a log nobody reads, so in
|
|
498
|
+
* practice it printed to nobody while the PM's skills and schedules quietly rendered as two empty
|
|
499
|
+
* lists. This puts the same sentence in the pane's own scrollback, which is a surface the PM is by
|
|
500
|
+
* definition looking at and which persists in the session record. Every new session, until the two
|
|
501
|
+
* directories are resolved, because a notice that shows once is a notice that is missed once.
|
|
502
|
+
*/
|
|
503
|
+
function announceStateHealth(s) {
|
|
504
|
+
const h = stateHealthNow();
|
|
505
|
+
if (h.ok) return;
|
|
506
|
+
appendBuf(s, `\x1b[38;5;203m✗ ${h.error}\x1b[0m\r\n`);
|
|
507
|
+
}
|
|
405
508
|
/**
|
|
406
509
|
* A freshly-live session meets its tree: take the claim if nobody holds one, and write what it
|
|
407
510
|
* found into its own scrollback. The pane line is deliberately part of the session's record rather
|
|
@@ -679,6 +782,153 @@ function looseDocs(root) {
|
|
|
679
782
|
}
|
|
680
783
|
return out;
|
|
681
784
|
}
|
|
785
|
+
/* ── SEARCH: the corpus `search everything…` is finally allowed to be ─────────────────────────
|
|
786
|
+
*
|
|
787
|
+
* The box shipped searching three DERIVED lists — initiative slugs, paths declared on an initiative
|
|
788
|
+
* record, and session names — so a file the board was never told about could not be found by typing
|
|
789
|
+
* its exact name. Worse, a miss rendered "nothing matches — a successful read of nothing", this
|
|
790
|
+
* repo's phrase for *we looked and there was nothing*, after a search that never opened the folder.
|
|
791
|
+
*
|
|
792
|
+
* Four rules hold this honest, and each one is asserted in the suite:
|
|
793
|
+
*
|
|
794
|
+
* 1. THE WALK IS THE FOLDER LENS'S WALK. Same `SKIP_DIRS`, same dotted-name exclusion, so search
|
|
795
|
+
* and the lens can never disagree about what exists in this folder. A third answer to "what is
|
|
796
|
+
* in here" would be worse than the missing feature.
|
|
797
|
+
* 2. NOTHING NEW IS REACHABLE. Every emitted path is `path.relative(root, …)` from a walk that
|
|
798
|
+
* STARTS at the root and only descends through `e.isDirectory()`. `readdirSync(…,
|
|
799
|
+
* {withFileTypes:true})` reports a symlink as `isSymbolicLink()` — neither a file nor a
|
|
800
|
+
* directory — so a link pointing out of the workspace is never followed and never emitted.
|
|
801
|
+
* There is no user-supplied path parameter here at all: the query is a needle, not a location.
|
|
802
|
+
* 3. IT IS BOUNDED, AND THE BOUND IS IN THE PAYLOAD. A capped list rendered as a complete one is
|
|
803
|
+
* the same lie the box already told, with a longer walk behind it — so every cap reports
|
|
804
|
+
* itself, and it must be FALSE when nothing was cut. The suite asserts both directions,
|
|
805
|
+
* because a flag that is always true is not a flag.
|
|
806
|
+
*
|
|
807
|
+
* TWO caps, TWO flags, deliberately not one. `truncated` means *files you searched for were
|
|
808
|
+
* never looked at* (the walk budget, the depth, or the result limit). `content_truncated`
|
|
809
|
+
* means *every file was named, but not every body was read* — which is the ordinary state of
|
|
810
|
+
* any repository carrying a lock file, and collapsing it into `truncated` would light the
|
|
811
|
+
* serious flag permanently and thereby retire it.
|
|
812
|
+
* 4. `kind` IS preview-kind's ANSWER, NOT A SECOND OPINION. The same classifier the read endpoints
|
|
813
|
+
* and the tree flag already use decides whether the reader can show a hit, so search can say
|
|
814
|
+
* "found, but not openable" instead of handing the PM a tab that prints a failure.
|
|
815
|
+
*/
|
|
816
|
+
const SEARCH_MAX_FILES = 20000; // walk budget — a giant repo (or a Drive mount) never hangs the box
|
|
817
|
+
const SEARCH_MAX_DEPTH = 12; // deeper than the lens's 6: search renders no tree, so depth is cheap
|
|
818
|
+
const SEARCH_CONTENT_FILES = 4000; // how many readable files get their BODIES scanned
|
|
819
|
+
const SEARCH_CONTENT_BYTES = 256 * 1024; // per file; a bigger one is path-matched only, never read whole
|
|
820
|
+
const SEARCH_LIMIT_DEFAULT = 40;
|
|
821
|
+
const SEARCH_LIMIT_MAX = 200;
|
|
822
|
+
const SEARCH_INDEX_TTL_MS = 4000; // one word of typing costs one walk, not one per keystroke
|
|
823
|
+
const SEARCH_SNIPPET = 160;
|
|
824
|
+
const SEARCH_OPENABLE = new Set(['markdown', 'text', 'html']); // what /api/doc will serve
|
|
825
|
+
const searchIndexCache = new Map(); // root -> { at, files, truncated, scanned }
|
|
826
|
+
|
|
827
|
+
/** Every file under `root`, flat and repo-relative, cached for SEARCH_INDEX_TTL_MS. */
|
|
828
|
+
function searchIndex(root) {
|
|
829
|
+
const now = Date.now();
|
|
830
|
+
const hit = searchIndexCache.get(root);
|
|
831
|
+
if (hit && now - hit.at < SEARCH_INDEX_TTL_MS) return hit;
|
|
832
|
+
const files = [];
|
|
833
|
+
let truncated = false;
|
|
834
|
+
(function walk(dir, rel, depth) {
|
|
835
|
+
if (truncated) return;
|
|
836
|
+
let ents = [];
|
|
837
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
838
|
+
for (const e of ents) {
|
|
839
|
+
if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue;
|
|
840
|
+
const full = path.join(dir, e.name);
|
|
841
|
+
const r = rel ? rel + '/' + e.name : e.name;
|
|
842
|
+
if (e.isDirectory()) {
|
|
843
|
+
// Depth is a bound like any other: reaching it means files below were NOT looked at.
|
|
844
|
+
if (depth < SEARCH_MAX_DEPTH) walk(full, r, depth + 1);
|
|
845
|
+
else truncated = true;
|
|
846
|
+
} else if (e.isFile()) {
|
|
847
|
+
if (files.length >= SEARCH_MAX_FILES) { truncated = true; return; }
|
|
848
|
+
files.push({ path: r, name: e.name, dir: rel, kind: preview.previewKind(e.name) });
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
})(root, '', 0);
|
|
852
|
+
const entry = { at: now, files, truncated, scanned: files.length };
|
|
853
|
+
searchIndexCache.set(root, entry);
|
|
854
|
+
return entry;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** One matched line, trimmed and centred on the needle, so a minified file cannot flood the row. */
|
|
858
|
+
function searchSnippet(line, at) {
|
|
859
|
+
const s = String(line || '').replace(/\t/g, ' ');
|
|
860
|
+
if (s.length <= SEARCH_SNIPPET) return s.trim();
|
|
861
|
+
const start = Math.max(0, at - Math.floor(SEARCH_SNIPPET / 3));
|
|
862
|
+
return (start > 0 ? '…' : '') + s.slice(start, start + SEARCH_SNIPPET).trim()
|
|
863
|
+
+ (start + SEARCH_SNIPPET < s.length ? '…' : '');
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Search one workspace. Path matches first (they are what the PM typed a name for), then content
|
|
868
|
+
* matches over the readable text files that did not already match by path.
|
|
869
|
+
* `{ ok, q, hits, truncated, content_truncated, scanned, content_scanned, limit }` — never throws,
|
|
870
|
+
* never 404s: an empty `hits` with `truncated:false` IS the honest "looked, found nothing".
|
|
871
|
+
*/
|
|
872
|
+
function searchWorkspace(root, query, limit) {
|
|
873
|
+
const q = String(query == null ? '' : query).trim();
|
|
874
|
+
const cap = Math.max(1, Math.min(SEARCH_LIMIT_MAX, parseInt(limit, 10) || SEARCH_LIMIT_DEFAULT));
|
|
875
|
+
if (!q) {
|
|
876
|
+
return { ok: true, q: '', hits: [], truncated: false, content_truncated: false,
|
|
877
|
+
scanned: 0, content_scanned: 0, limit: cap };
|
|
878
|
+
}
|
|
879
|
+
const needle = q.toLowerCase();
|
|
880
|
+
const idx = searchIndex(root);
|
|
881
|
+
const hits = [];
|
|
882
|
+
const seen = new Set();
|
|
883
|
+
let overflow = false; // more matched than `cap` — a REAL cut, not a coincidence
|
|
884
|
+
|
|
885
|
+
for (const f of idx.files) {
|
|
886
|
+
if (!f.path.toLowerCase().includes(needle)) continue;
|
|
887
|
+
seen.add(f.path);
|
|
888
|
+
if (hits.length >= cap) { overflow = true; break; }
|
|
889
|
+
hits.push({ ...f, match: 'path', line: null, snippet: null, openable: SEARCH_OPENABLE.has(f.kind) });
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
let read = 0;
|
|
893
|
+
let contentCut = false;
|
|
894
|
+
if (!overflow) {
|
|
895
|
+
for (const f of idx.files) {
|
|
896
|
+
if (seen.has(f.path) || !SEARCH_OPENABLE.has(f.kind)) continue;
|
|
897
|
+
if (read >= SEARCH_CONTENT_FILES) { contentCut = true; break; }
|
|
898
|
+
const full = path.join(root, f.path);
|
|
899
|
+
let st;
|
|
900
|
+
try { st = fs.statSync(full); } catch { continue; }
|
|
901
|
+
if (!st.isFile()) continue;
|
|
902
|
+
if (st.size > SEARCH_CONTENT_BYTES) { contentCut = true; continue; }
|
|
903
|
+
read += 1;
|
|
904
|
+
let text;
|
|
905
|
+
try { text = fs.readFileSync(full, 'utf8'); } catch { continue; }
|
|
906
|
+
if (text.indexOf(String.fromCharCode(0)) !== -1) continue; // binary masquerading as text — never scanned
|
|
907
|
+
const at = text.toLowerCase().indexOf(needle);
|
|
908
|
+
if (at === -1) continue;
|
|
909
|
+
if (hits.length >= cap) { overflow = true; break; }
|
|
910
|
+
const before = text.slice(0, at);
|
|
911
|
+
const lineNo = before.split('\n').length;
|
|
912
|
+
const lineStart = before.lastIndexOf('\n') + 1;
|
|
913
|
+
const lineEnd = text.indexOf('\n', at);
|
|
914
|
+
hits.push({
|
|
915
|
+
...f, match: 'content', line: lineNo,
|
|
916
|
+
snippet: searchSnippet(text.slice(lineStart, lineEnd === -1 ? text.length : lineEnd), at - lineStart),
|
|
917
|
+
openable: true,
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
return {
|
|
923
|
+
ok: true, q, hits, limit: cap,
|
|
924
|
+
// "files you searched for were never looked at" — the serious one.
|
|
925
|
+
truncated: idx.truncated || overflow,
|
|
926
|
+
// "every file was named; not every body was read" — the ordinary one.
|
|
927
|
+
content_truncated: contentCut,
|
|
928
|
+
scanned: idx.scanned, content_scanned: read,
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
|
|
682
932
|
// Serve a TEXTUAL document — markdown (incl. .skill), plain-text/config source, or HTML source —
|
|
683
933
|
// per-workspace-guarded. Images are NOT served here (they stream through /api/asset); anything
|
|
684
934
|
// non-textual returns null so the reader shows it inert. A NUL byte ⇒ binary masquerading as text
|
|
@@ -1033,10 +1283,32 @@ function deriveState(ev) {
|
|
|
1033
1283
|
// state dir is why this surface reported 0 accepted against 23 real acceptance rows — the same
|
|
1034
1284
|
// "one field, several readers, none agreeing" defect the anchor resolver was built to end.
|
|
1035
1285
|
function acceptanceFilesFor(w) {
|
|
1286
|
+
return ledgerFilesFor(w, 'acceptances.jsonl');
|
|
1287
|
+
}
|
|
1288
|
+
// The general form of the comment above, because the defect it describes was never specific to
|
|
1289
|
+
// acceptances. `deriveNeeds` read evals.jsonl, acceptances.jsonl AND outcome_checks.jsonl from the
|
|
1290
|
+
// state dir alone, so the supervision surface under-reported everything pmos-self records in
|
|
1291
|
+
// planning/runs/ — the same "one field, several readers, none agreeing" shape, three ledgers wide.
|
|
1292
|
+
// One helper with three call sites, so the fourth ledger added later cannot reintroduce it.
|
|
1293
|
+
function ledgerFilesFor(w, basename) {
|
|
1036
1294
|
const out = [];
|
|
1037
1295
|
const d = stateDirFor(w);
|
|
1038
|
-
if (d) out.push(path.join(d,
|
|
1039
|
-
if (w && w.root) out.push(path.join(w.root, 'planning', 'runs',
|
|
1296
|
+
if (d) out.push(path.join(d, basename));
|
|
1297
|
+
if (w && w.root) out.push(path.join(w.root, 'planning', 'runs', basename));
|
|
1298
|
+
return out;
|
|
1299
|
+
}
|
|
1300
|
+
// readJsonl over BOTH ledger locations, deduped by nothing: callers fold, and a row present in both
|
|
1301
|
+
// is the same row recorded twice, which every existing fold already tolerates.
|
|
1302
|
+
function readLedger(w, basename) {
|
|
1303
|
+
const out = [];
|
|
1304
|
+
for (const f of ledgerFilesFor(w, basename)) {
|
|
1305
|
+
let text = '';
|
|
1306
|
+
try { text = fs.readFileSync(f, 'utf8'); } catch { continue; }
|
|
1307
|
+
for (const line of text.split('\n')) {
|
|
1308
|
+
if (!line.trim()) continue;
|
|
1309
|
+
try { out.push(JSON.parse(line)); } catch { /* a torn line is reported by the checkers, not here */ }
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1040
1312
|
return out;
|
|
1041
1313
|
}
|
|
1042
1314
|
function readAcceptances(w) {
|
|
@@ -1355,19 +1627,40 @@ function buildSurface(name, root, w) {
|
|
|
1355
1627
|
// (D62). v1 tasks are DETERMINISTIC (generated from a surface + the contract = maximally consistent,
|
|
1356
1628
|
// no agent needed); agent-backed tasks (reasoning work) that spawn a PTY session are the documented
|
|
1357
1629
|
// extension, not v1. Cadence math is the pure shared module — the tick and the pane's next-run agree.
|
|
1358
|
-
|
|
1630
|
+
// Resolved per call (schedStore/skillsStore, above): normally `STATE/…`, and under any unsettled
|
|
1631
|
+
// state root the legacy copy where the per-user root holds none — read AND written, since it is
|
|
1632
|
+
// then the only copy on the machine. stateHealthNow() says so on every surface that lists them.
|
|
1633
|
+
// A function rather than a constant so that resolving the conflict, or fixing the permission that
|
|
1634
|
+
// refused the move, takes effect on the next read instead of on the next restart.
|
|
1635
|
+
function schedFile() { return schedStore().path; }
|
|
1359
1636
|
const DELIV_DIR = path.join(STATE, 'deliverables');
|
|
1360
1637
|
// Skills are file-driven (workspace-skill-authoring): built-ins ship committed under workspace/skills/,
|
|
1361
1638
|
// user-authored ones are scaffolded into state/skills/ by the New Skill flow. There is ONE user-facing
|
|
1362
1639
|
// concept — a skill — so the picker never exposes the internal `run`/`kind`; how a skill runs (server
|
|
1363
1640
|
// derives it instantly, or a claude PTY session reasons through it) is decided by the file, not the PM.
|
|
1364
1641
|
const BUILTIN_SKILLS_DIR = path.join(__dirname, 'skills');
|
|
1365
|
-
|
|
1642
|
+
function userSkillsDir() { return skillsStore().path; }
|
|
1643
|
+
// The two paths a scheduled agent run must file into, spelled ABSOLUTELY. They used to be written
|
|
1644
|
+
// into the scaffold as `workspace/state/…`, which stopped being true the moment runtime state moved
|
|
1645
|
+
// out of the application directory (lib/state-root.js) — and a scaffold naming a directory the
|
|
1646
|
+
// console never reads is a report that silently never arrives. Derived from the same constants the
|
|
1647
|
+
// reader uses, so the two can only agree.
|
|
1648
|
+
//
|
|
1649
|
+
// `legacy` names where the runtime state USED to live — this application directory's own `state/`.
|
|
1650
|
+
// The retarget needs it because the PM's nine idpf-* skills spell the old location ABSOLUTELY
|
|
1651
|
+
// (`/Users/…/pmos/workspace/state/deliverables/<run-id>.md`), so there is no leading quote or space
|
|
1652
|
+
// to anchor a match on; the only question that can be answered reliably is "is this path's parent
|
|
1653
|
+
// the old state root", and only the caller knows where that was. Derived from __dirname, never
|
|
1654
|
+
// configured, so it cannot drift from the directory the migration moved out of.
|
|
1655
|
+
function filingContract(w) {
|
|
1656
|
+
return { deliverables: DELIV_DIR, needs: needsFileFor(w) || path.join(STATE, 'needs.json'),
|
|
1657
|
+
legacy: LEGACY_STATE };
|
|
1658
|
+
}
|
|
1366
1659
|
// Read the skill files fresh each call so a just-created user skill appears with no restart. User dirs
|
|
1367
1660
|
// load FIRST and built-ins LAST, so a built-in's load-bearing deterministic fast-path can't be shadowed.
|
|
1368
1661
|
function loadTasks() {
|
|
1369
1662
|
return skillStore.loadSkills([
|
|
1370
|
-
{ dir:
|
|
1663
|
+
{ dir: userSkillsDir(), source: 'user' },
|
|
1371
1664
|
{ dir: BUILTIN_SKILLS_DIR, source: 'builtin' },
|
|
1372
1665
|
]).map(toTask);
|
|
1373
1666
|
}
|
|
@@ -1382,10 +1675,10 @@ function toTask(s) {
|
|
|
1382
1675
|
}
|
|
1383
1676
|
function taskById(id) { return loadTasks().find((t) => t.id === id || t.skill === id) || null; }
|
|
1384
1677
|
function readSchedules() {
|
|
1385
|
-
try { const a = JSON.parse(fs.readFileSync(
|
|
1678
|
+
try { const a = JSON.parse(fs.readFileSync(schedFile(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; }
|
|
1386
1679
|
}
|
|
1387
1680
|
function writeSchedules(list) {
|
|
1388
|
-
try { fs.writeFileSync(
|
|
1681
|
+
try { fs.writeFileSync(schedFile(), JSON.stringify(list)); }
|
|
1389
1682
|
catch (e) { warnWriteFail('schedules.json (scheduled tasks)', e); }
|
|
1390
1683
|
}
|
|
1391
1684
|
|
|
@@ -1407,11 +1700,20 @@ function fireSchedule(sched) {
|
|
|
1407
1700
|
// AGENT task — fire as a real PTY session running its skill (async; the skill files its own
|
|
1408
1701
|
// deliverable). The workspace starts it + stamps 'started'; run-now and the tick share this path.
|
|
1409
1702
|
if (task && task.kind === 'agent') {
|
|
1703
|
+
// THE FILING CONTRACT IS RETARGETED HERE, on the prompt, at the moment it is handed to the
|
|
1704
|
+
// session (lib/skill-store.js#retargetFiling). The PM's eleven existing skills were authored
|
|
1705
|
+
// when runtime state lived at `workspace/state/`, so their bodies still say "write the report
|
|
1706
|
+
// to workspace/state/deliverables/". After the relocation that is a directory the console
|
|
1707
|
+
// never reads: the agent would succeed, report success, and the report would never arrive.
|
|
1708
|
+
// Fixing only the SCAFFOLD fixes only skills that do not exist yet, which is no help to the
|
|
1709
|
+
// eleven that do. `needs.json` is resolved per workspace, so this cannot be a one-off rewrite
|
|
1710
|
+
// of the files either — the right answer for a given fire depends on which repo it fires in.
|
|
1711
|
+
const prompt = task.prompt ? skillStore.retargetFiling(task.prompt, filingContract(w)) : null;
|
|
1410
1712
|
// The schedule's recorded profile travels with the process it creates (profiles R8.3). Only an
|
|
1411
1713
|
// AGENT-kind task has a PTY, which is why `profile_applies` is false for the skill-kind rows
|
|
1412
1714
|
// below rather than the control simply being hidden.
|
|
1413
1715
|
const s = spawnSession({ name: sched.name, agent: task.agent || 'claude', ws: sched.ws,
|
|
1414
|
-
origin: 'schedule:' + sched.id, initialInput:
|
|
1716
|
+
origin: 'schedule:' + sched.id, initialInput: prompt ? prompt + '\r' : null,
|
|
1415
1717
|
profile: sched.profile == null ? null : sched.profile });
|
|
1416
1718
|
const list = readSchedules(); const cur = list.find((x) => x.id === sched.id);
|
|
1417
1719
|
const started = s.state !== 'exited';
|
|
@@ -2247,6 +2549,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
2247
2549
|
s.buf = `\x1b[38;5;203m✗ ${why}\x1b[0m\r\n\x1b[38;5;244mFix the above, then ↻ respawn.\x1b[0m\r\n`;
|
|
2248
2550
|
persist(s); pushSessions(); pushWorkspaces(); return s;
|
|
2249
2551
|
}
|
|
2552
|
+
announceStateHealth(s); // the unmigrated notice comes FIRST — it explains an empty skills list
|
|
2250
2553
|
announceTree(s); // before attach, so the advisory is the first line of the pane's record
|
|
2251
2554
|
attachPty(s);
|
|
2252
2555
|
feed(s);
|
|
@@ -2266,6 +2569,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
2266
2569
|
pty: p, tmux: null, note: '', buf: '', cols: 80, rows: 24, clients: new Set(), created: nowISO(),
|
|
2267
2570
|
lastOutputAt: Date.now() };
|
|
2268
2571
|
sessions.set(id, s);
|
|
2572
|
+
announceStateHealth(s);
|
|
2269
2573
|
announceTree(s); // registered first: a session's co-tenancy question includes itself in the map
|
|
2270
2574
|
p.onData((d) => {
|
|
2271
2575
|
appendBuf(s, d);
|
|
@@ -2607,7 +2911,11 @@ function stateDirFor(w, { create = false } = {}) {
|
|
|
2607
2911
|
const d = path.join(w.root, ...rel);
|
|
2608
2912
|
if (isDir(d)) return d;
|
|
2609
2913
|
}
|
|
2610
|
-
|
|
2914
|
+
// pmos-self with no pmos/state/ yet: the checkout's OWN state/ is the file-mode mirror. This is
|
|
2915
|
+
// deliberately LEGACY_STATE and not STATE — D64's system of record belongs to the repository that
|
|
2916
|
+
// earned it, so it must not follow the runtime state out to the per-user directory. (Never a
|
|
2917
|
+
// tenant home — that writes to its OWN repo below.)
|
|
2918
|
+
if (EMBEDDED_SELF && w.root === SELF_ROOT) return LEGACY_STATE;
|
|
2611
2919
|
// A PMOS-shaped repo with no state dir yet gets the kit's own layout on first write. Never
|
|
2612
2920
|
// invented for a plain repo — there we fall back and SAY SO rather than write somewhere wrong.
|
|
2613
2921
|
if (create && wsShape(w.root) === 'pmos') {
|
|
@@ -2655,18 +2963,20 @@ function deriveNeeds(w) {
|
|
|
2655
2963
|
if (!dir) return [];
|
|
2656
2964
|
const needs = [];
|
|
2657
2965
|
const settled = new Set();
|
|
2658
|
-
for (const a of
|
|
2659
|
-
for (const e of
|
|
2966
|
+
for (const a of readLedger(w, 'acceptances.jsonl')) if (a.run_id) settled.add(a.run_id);
|
|
2967
|
+
for (const e of readLedger(w, 'evals.jsonl')) {
|
|
2660
2968
|
if (!e.run_id || settled.has(e.run_id)) continue;
|
|
2661
2969
|
const verdict = e.verdict ? String(e.verdict) : 'no verdict';
|
|
2662
2970
|
needs.push({
|
|
2663
2971
|
run_id: e.run_id, initiative_id: e.initiative_id || null, kind: 'accept', derived: true,
|
|
2664
2972
|
question: `Acceptance Gate open — evaluator says ${verdict}${e.score != null ? ` (${e.score})` : ''}`,
|
|
2665
|
-
|
|
2973
|
+
// `rubric` is the deprecated alias every one of the 26 legacy rows uses; scripts/record-eval.js
|
|
2974
|
+
// requires `rubric_path` going forward. Read both, write only the new one.
|
|
2975
|
+
doc: e.rubric_path || e.rubric || null,
|
|
2666
2976
|
});
|
|
2667
2977
|
}
|
|
2668
2978
|
const disposed = new Set();
|
|
2669
|
-
for (const c of
|
|
2979
|
+
for (const c of readLedger(w, 'outcome_checks.jsonl')) if (c.initiative_id) disposed.add(c.initiative_id);
|
|
2670
2980
|
const today = nowISO().slice(0, 10);
|
|
2671
2981
|
let files = [];
|
|
2672
2982
|
try { files = fs.readdirSync(path.join(dir, 'initiatives')).filter((f) => f.endsWith('.md')).sort(); } catch {}
|
|
@@ -3408,7 +3718,9 @@ async function handleRequest(req, res) {
|
|
|
3408
3718
|
return json(res, { ok: true, tree, writer: s.id, previous: prev || null, advisory: true,
|
|
3409
3719
|
note: 'a declaration, not a guarantee — nothing about either process changed' });
|
|
3410
3720
|
}
|
|
3411
|
-
|
|
3721
|
+
// `state_health` rides on the payload EVERY client reads at boot, so the unmigrated notice reaches
|
|
3722
|
+
// the PM whichever lens they open first — not only the two lists it is about.
|
|
3723
|
+
if (p === '/api/workspaces' && req.method === 'GET') return json(res, { workspaces: wsList(), state_health: stateHealthNow() });
|
|
3412
3724
|
if (p === '/api/workspaces' && req.method === 'POST') {
|
|
3413
3725
|
// How `pmos` in another repo becomes another workspace (R1). Local-only, like every
|
|
3414
3726
|
// route here — the server binds 127.0.0.1 and this only records a path.
|
|
@@ -3684,16 +3996,21 @@ async function handleRequest(req, res) {
|
|
|
3684
3996
|
if (p === '/api/skills' && req.method === 'GET') {
|
|
3685
3997
|
const skills = loadTasks().map((t) => ({ id: t.id, label: t.label, skill: t.skill,
|
|
3686
3998
|
description: t.description || '', output: t.output || [], source: t.source || 'builtin' }));
|
|
3687
|
-
|
|
3999
|
+
// NEVER a bare list. Under an unmigrated conflict this list is either short by every skill the
|
|
4000
|
+
// PM authored or is being read from the old directory, and in both cases the payload has to say
|
|
4001
|
+
// so — a calm list is a claim that the state is settled (see stateHealthNow).
|
|
4002
|
+
return json(res, { skills, state_health: stateHealthNow() });
|
|
3688
4003
|
}
|
|
3689
4004
|
// POST scaffolds a well-formed .skill file from name + "what it should do" + the output shape.
|
|
3690
4005
|
// A GUI replaces editing server code; user skills default to running as a claude PTY session.
|
|
3691
4006
|
if (p === '/api/skills' && req.method === 'POST') {
|
|
3692
4007
|
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
|
|
3693
|
-
|
|
4008
|
+
// `filing` is built HERE, never taken from the request body: it names filesystem paths, and a
|
|
4009
|
+
// caller-supplied one would be a write instruction dressed up as a preference.
|
|
4010
|
+
const built = skillStore.scaffoldSkill({ ...body, filing: filingContract(getWs(body.ws)) });
|
|
3694
4011
|
if (!built.ok) return json(res, { ok: false, error: built.error }, 400);
|
|
3695
4012
|
if (loadTasks().some((t) => t.id === built.slug)) return json(res, { ok: false, error: `a skill named "${built.slug}" already exists` }, 409);
|
|
3696
|
-
try { fs.mkdirSync(
|
|
4013
|
+
try { const d = userSkillsDir(); fs.mkdirSync(d, { recursive: true }); fs.writeFileSync(path.join(d, built.slug + '.skill'), built.content); }
|
|
3697
4014
|
catch (e) { return json(res, { ok: false, error: 'could not write the skill file' }, 500); }
|
|
3698
4015
|
return json(res, { ok: true, skill: { id: built.slug, label: built.label, skill: built.slug,
|
|
3699
4016
|
description: built.description, output: built.output, source: 'user' } });
|
|
@@ -3703,13 +4020,15 @@ async function handleRequest(req, res) {
|
|
|
3703
4020
|
// Always leaves a valid file even where claude is absent; the session is honest about not starting.
|
|
3704
4021
|
if (p === '/api/skills/draft' && req.method === 'POST') {
|
|
3705
4022
|
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
|
|
3706
|
-
const
|
|
4023
|
+
const w0 = getWs(body.ws);
|
|
4024
|
+
const filing = filingContract(w0);
|
|
4025
|
+
const built = skillStore.scaffoldSkill({ ...body, filing });
|
|
3707
4026
|
if (!built.ok) return json(res, { ok: false, error: built.error }, 400);
|
|
3708
4027
|
if (loadTasks().some((t) => t.id === built.slug)) return json(res, { ok: false, error: `a skill named "${built.slug}" already exists` }, 409);
|
|
3709
|
-
try { fs.mkdirSync(
|
|
4028
|
+
try { const d = userSkillsDir(); fs.mkdirSync(d, { recursive: true }); fs.writeFileSync(path.join(d, built.slug + '.skill'), built.content); }
|
|
3710
4029
|
catch { return json(res, { ok: false, error: 'could not write the skill file' }, 500); }
|
|
3711
|
-
const w =
|
|
3712
|
-
const rel = path.relative(w ? w.root : SELF_ROOT, path.join(
|
|
4030
|
+
const w = w0;
|
|
4031
|
+
const rel = path.relative(w ? w.root : SELF_ROOT, path.join(userSkillsDir(), built.slug + '.skill'));
|
|
3713
4032
|
const prompt = `Refine the PMOS workspace skill file at ${rel}. It was scaffolded from this ask: ` +
|
|
3714
4033
|
`"${built.description || built.label}". First read skills/core/SKILL-FORMAT.md (the skill spec) and ` +
|
|
3715
4034
|
`follow build-skills/skill-creator.skill (the authoring procedure). Keep the runtime frontmatter shape ` +
|
|
@@ -3717,7 +4036,7 @@ async function handleRequest(req, res) {
|
|
|
3717
4036
|
`ALWAYS produce). Sharpen the BODY into a procedure-first, repeatable procedure grounded in THIS repository's ` +
|
|
3718
4037
|
`own files and tools only (no network): a Goal line, then numbered steps each ending on a checkable "Done when:" ` +
|
|
3719
4038
|
`condition. Keep the FILING CONTRACT the scaffold wrote — the run must write its deliverable to ` +
|
|
3720
|
-
|
|
4039
|
+
`${filing.deliverables}/<run-id>.md and append the structured object to ${filing.needs} ` +
|
|
3721
4040
|
`(a valid JSON array), never a prose note. Then run the SKILL-FORMAT conformance checklist (items 6-9: ` +
|
|
3722
4041
|
`predictability, per-step completion criteria, pruning, leading words) over the body — put no self-check block ` +
|
|
3723
4042
|
`in the file itself. Then stop.`;
|
|
@@ -3787,7 +4106,9 @@ async function handleRequest(req, res) {
|
|
|
3787
4106
|
profile: s.profile == null ? null : s.profile,
|
|
3788
4107
|
profile_applies: !!(t && t.kind === 'agent') };
|
|
3789
4108
|
});
|
|
3790
|
-
|
|
4109
|
+
// Same rule as /api/skills: an empty (or fallen-back) schedule list under a conflict must never
|
|
4110
|
+
// read as "you have no scheduled tasks". A schedule that never loads never fires.
|
|
4111
|
+
return json(res, { schedules: list, now: nowISO(), state_health: stateHealthNow() });
|
|
3791
4112
|
}
|
|
3792
4113
|
if (p === '/api/schedules' && req.method === 'POST') {
|
|
3793
4114
|
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
|
|
@@ -3988,7 +4309,7 @@ async function handleRequest(req, res) {
|
|
|
3988
4309
|
// Stripped by spreading rather than deleting — some surfaces return frozen objects.
|
|
3989
4310
|
const code = (out && Number.isFinite(out.http_status)) ? out.http_status : 200;
|
|
3990
4311
|
if (out && out.http_status != null) { out = { ...out }; delete out.http_status; }
|
|
3991
|
-
return json(res, { name, ...out }, code);
|
|
4312
|
+
return json(res, { name, ...out, state_health: stateHealthNow() }, code);
|
|
3992
4313
|
}
|
|
3993
4314
|
// ── ONE initiative, assembled (the four-panel surface). ────────────────────────────────────
|
|
3994
4315
|
// Panel 3 needs the record, its artifacts, its ladder, its external references, its anchor and
|
|
@@ -4159,6 +4480,25 @@ async function handleRequest(req, res) {
|
|
|
4159
4480
|
});
|
|
4160
4481
|
return json(res, built, built.ok ? 200 : 400);
|
|
4161
4482
|
}
|
|
4483
|
+
// ── TWO CORPORA, TWO ENDPOINTS, ONE BOX ────────────────────────────────────────────────────
|
|
4484
|
+
// `/api/search` is EPISODIC — what a past session printed. `/api/search/files` is the FOLDER —
|
|
4485
|
+
// what is on disk under this workspace's root. They are deliberately not merged: the hit shapes
|
|
4486
|
+
// share no field (`{session, line, after}` vs `{path, kind, match, openable}`), the scrollback
|
|
4487
|
+
// search scrubs secrets out of text an agent typed, and the file search reports a walk bound.
|
|
4488
|
+
// Collapsing them into one payload would mean one of the two lying about what it looked at.
|
|
4489
|
+
if (p === '/api/search/files') {
|
|
4490
|
+
// `search everything…`, finally over everything: every file under THIS workspace's root, by
|
|
4491
|
+
// path and by content. There is no path parameter — the query is a needle, not a location — so
|
|
4492
|
+
// this adds no reachable path; `openable` reports whether /api/doc (or /api/asset) could show a
|
|
4493
|
+
// hit. `truncated` + `scanned` are the contract that keeps a bounded answer from reading as a
|
|
4494
|
+
// complete one, which is the defect this endpoint exists to end rather than to relocate.
|
|
4495
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
4496
|
+
if (!w) {
|
|
4497
|
+
return json(res, { ok: false, error: 'no workspace to search', q: url.searchParams.get('q') || '',
|
|
4498
|
+
hits: [], truncated: false, scanned: 0, content_scanned: 0, limit: SEARCH_LIMIT_DEFAULT });
|
|
4499
|
+
}
|
|
4500
|
+
return json(res, { ws: w.id, ...searchWorkspace(w.root, url.searchParams.get('q'), url.searchParams.get('limit')) });
|
|
4501
|
+
}
|
|
4162
4502
|
if (p === '/api/search') {
|
|
4163
4503
|
// Episodic recall over this workspace's own past sessions. Localhost only, like everything
|
|
4164
4504
|
// else here — an agent running in a pane can curl it to check what an earlier run tried.
|
|
@@ -4192,12 +4532,16 @@ async function handleRequest(req, res) {
|
|
|
4192
4532
|
// response so the client can be honest about where the decision landed.
|
|
4193
4533
|
let sink = acceptancesFileFor(w, { create: true });
|
|
4194
4534
|
const fallback = !sink;
|
|
4195
|
-
|
|
4535
|
+
// LEGACY_STATE, not STATE: an acceptance is a D64 record, and the last-resort sink for a repo
|
|
4536
|
+
// whose state directory could not be resolved stays with the install rather than following the
|
|
4537
|
+
// runtime state out to the per-user directory, where nobody would think to look for a record.
|
|
4538
|
+
if (fallback) sink = path.join(LEGACY_STATE, 'acceptances.jsonl');
|
|
4196
4539
|
// FAIL LOUD (D64 Track A2): under D64 the file-mode acceptances log is the SYSTEM OF RECORD.
|
|
4197
4540
|
// A swallowed append + {ok:true} would light the gate green over a record that never landed —
|
|
4198
4541
|
// exactly the false-green class D64 exists to delete. On write failure return HTTP 500 and
|
|
4199
4542
|
// {ok:false}, and do NOT report success. The error text carries no header/secret content (D08).
|
|
4200
4543
|
try {
|
|
4544
|
+
if (fallback) fs.mkdirSync(LEGACY_STATE, { recursive: true }); // the install's state/ may not exist yet
|
|
4201
4545
|
fs.appendFileSync(sink, JSON.stringify(rec) + '\n');
|
|
4202
4546
|
} catch (e) {
|
|
4203
4547
|
warnWriteFail('acceptances.jsonl (Acceptance Gate — system of record)', e);
|