@litfamily/litmuse 0.1.1 → 0.1.3
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/CHANGELOG.md +21 -0
- package/README.md +23 -1
- package/README_ko-KR.md +23 -1
- package/TRADEMARK.md +12 -0
- package/bin/litmuse.mjs +4 -0
- package/lib/stage-card.mjs +249 -6
- package/package.json +3 -2
- package/plugin/.muse-plugin/plugin.json +26 -11
- package/plugin/commands/lit-status.md +3 -1
- package/plugin/commands/lit.md +1 -1
- package/plugin/hooks/lib/digest.mjs +1 -1
- package/plugin/hooks/lib/next-step.mjs +112 -0
- package/plugin/hooks/lib/routes.mjs +59 -0
- package/plugin/hooks/lib/stage.mjs +53 -6
- package/plugin/hooks/lib/workflow-stack.mjs +336 -0
- package/plugin/hooks/post-tool-use-failure.mjs +2 -0
- package/plugin/hooks/post-tool-use.mjs +2 -0
- package/plugin/hooks/session-end.mjs +2 -1
- package/plugin/hooks/session-start.mjs +32 -2
- package/plugin/hooks/stop.mjs +23 -3
- package/plugin/hooks/user-prompt-submit.mjs +138 -7
- package/plugin/lib/capped-fetch.mjs +25 -0
- package/plugin/lib/pinned-fetch.mjs +79 -0
- package/plugin/lib/plan-task-rows.mjs +32 -0
- package/plugin/lib/render_pages.py +29 -1
- package/plugin/lib/rewind/collect.mjs +808 -0
- package/plugin/lib/rewind/gauge.mjs +72 -0
- package/plugin/lib/rewind/next-step.mjs +91 -0
- package/plugin/lib/rewind/redact.mjs +83 -0
- package/plugin/lib/rewind/transcripts.mjs +229 -0
- package/plugin/skills/frontend-ui-ux/SKILL.md +24 -9
- package/plugin/skills/frontend-ui-ux/kit/components.css +414 -0
- package/plugin/skills/frontend-ui-ux/kit/fonts.json +22 -0
- package/plugin/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
- package/plugin/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
- package/plugin/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
- package/plugin/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
- package/plugin/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
- package/plugin/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
- package/plugin/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
- package/plugin/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
- package/plugin/skills/frontend-ui-ux/kit/ui.js +226 -0
- package/plugin/skills/frontend-ui-ux/references/complete-contract.md +2 -1
- package/plugin/skills/frontend-ui-ux/references/craft-floor.md +9 -4
- package/plugin/skills/frontend-ui-ux/references/default-editorial-pixel.json +59 -7
- package/plugin/skills/frontend-ui-ux/references/house-styles.md +234 -0
- package/plugin/skills/frontend-ui-ux/references/motion-guide.md +3 -3
- package/plugin/skills/frontend-ui-ux/references/slop-register.md +7 -7
- package/plugin/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
- package/plugin/skills/frontend-ui-ux/scripts/interface-probe-lock.mjs +196 -0
- package/plugin/skills/frontend-ui-ux/scripts/interface-probe-page.mjs +31 -10
- package/plugin/skills/frontend-ui-ux/scripts/interface-probe-rules.mjs +19 -7
- package/plugin/skills/frontend-ui-ux/scripts/interface-probe-static.mjs +4 -0
- package/plugin/skills/frontend-ui-ux/scripts/interface-probe.mjs +59 -7
- package/plugin/skills/frontend-ui-ux/scripts/pixel-art.mjs +60 -0
- package/plugin/skills/lit-adhd/SKILL.md +195 -0
- package/plugin/skills/lit-adhd/references/card-examples.md +121 -0
- package/plugin/skills/lit-adhd/references/status-taxonomy.md +45 -0
- package/plugin/skills/lit-adhd/scripts/rewind.mjs +11 -0
- package/plugin/skills/lit-diagram-drawer/SKILL.md +3 -3
- package/plugin/skills/lit-diagram-drawer/scripts/diagram-craft-rules.mjs +1 -2
- package/plugin/skills/lit-diagram-drawer/scripts/visual-quality.mjs +0 -1
- package/plugin/skills/lit-docx/SKILL.md +1 -1
- package/plugin/skills/lit-hijack/SKILL.md +589 -0
- package/plugin/skills/lit-hijack/references/capture-and-provenance.md +123 -0
- package/plugin/skills/lit-hijack/references/clean-room-build.md +133 -0
- package/plugin/skills/lit-hijack/references/distil-and-licence.md +128 -0
- package/plugin/skills/lit-hijack/references/gap-and-port-design.md +152 -0
- package/plugin/skills/lit-hijack/references/parity-and-report.md +133 -0
- package/plugin/skills/lit-hijack/scripts/hijack.mjs +879 -0
- package/plugin/skills/lit-plan/SKILL.md +12 -1
- package/plugin/skills/lit-plan/scripts/check-plan.mjs +71 -4
- package/plugin/skills/lit-pptx/SKILL.md +20 -9
- package/plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
- package/plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
- package/plugin/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
- package/plugin/skills/lit-pptx/fonts/provenance.json +32 -1
- package/plugin/skills/lit-pptx/references/direction-step.md +19 -2
- package/plugin/skills/lit-pptx/references/examples/13-engineering-review-computational-brutalism-ko.md +173 -0
- package/plugin/skills/lit-pptx/references/examples/14-launch-pitch-neo-brutalism-ko.md +154 -0
- package/plugin/skills/lit-pptx/references/examples/15-design-lecture-bauhaus-ko.md +139 -0
- package/plugin/skills/lit-pptx/references/examples/16-community-programme-riso-ko.md +158 -0
- package/plugin/skills/lit-pptx/references/examples/17-public-data-briefing-newsroom-ko.md +179 -0
- package/plugin/skills/lit-pptx/references/tonalities/bauhaus.md +112 -0
- package/plugin/skills/lit-pptx/references/tonalities/computational-brutalism.md +118 -0
- package/plugin/skills/lit-pptx/references/tonalities/neo-brutalism.md +125 -0
- package/plugin/skills/lit-pptx/references/tonalities/newsroom.md +115 -0
- package/plugin/skills/lit-pptx/references/tonalities/riso.md +109 -0
- package/plugin/skills/lit-pptx/scripts/compile-deck.js +1 -1
- package/plugin/skills/lit-pptx/scripts/craft_extras.py +136 -92
- package/plugin/skills/lit-pptx/scripts/embed_fonts.py +13 -1
- package/plugin/skills/lit-pptx/scripts/lib/grid-resolver.js +105 -116
- package/plugin/skills/lit-pptx/scripts/lib/layout-resolver.js +9 -15
- package/plugin/skills/lit-pptx/scripts/lib/render-pack.js +368 -561
- package/plugin/skills/lit-pptx/scripts/lib/slide-grid.js +104 -0
- package/plugin/skills/lit-pptx/scripts/lib/template-registry.js +57 -6
- package/plugin/skills/lit-pptx/scripts/qa_deck.py +4 -4
- package/plugin/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +1 -1
- package/plugin/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +98 -0
- package/plugin/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +1 -1
- package/plugin/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
- package/plugin/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +1 -1
- package/plugin/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +1 -1
- package/plugin/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
- package/plugin/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
- package/plugin/skills/lit-pptx/templates/tonalities/night/pack.yaml +1 -1
- package/plugin/skills/lit-pptx/templates/tonalities/paper/pack.yaml +1 -1
- package/plugin/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
- package/plugin/skills/lit-pptx/templates/tonalities/signal/pack.yaml +2 -2
- package/plugin/skills/lit-pptx/templates/tonalities/studio/pack.yaml +1 -1
- package/plugin/skills/lit-recap/SKILL.md +3 -0
- package/plugin/skills/lit-schematic/SKILL.md +241 -0
- package/plugin/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +14 -0
- package/plugin/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +8 -0
- package/plugin/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +22 -0
- package/plugin/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +73 -0
- package/plugin/skills/lit-schematic/references/cutaway-outputs.md +25 -0
- package/plugin/skills/lit-schematic/references/isometric-craft.md +24 -0
- package/plugin/skills/lit-schematic/references/journal-figures.md +19 -0
- package/plugin/skills/lit-schematic/references/kit-api.md +27 -0
- package/plugin/skills/lit-schematic/references/pfd-and-layout.md +20 -0
- package/plugin/skills/lit-schematic/references/pid-drafting.md +26 -0
- package/plugin/skills/lit-schematic/references/pid-symbols.md +27 -0
- package/plugin/skills/lit-schematic/references/verification.md +24 -0
- package/plugin/skills/lit-schematic/scripts/doctor.mjs +13 -0
- package/plugin/skills/lit-schematic/scripts/lib/figure.mjs +49 -0
- package/plugin/skills/lit-schematic/scripts/lib/isa.mjs +18 -0
- package/plugin/skills/lit-schematic/scripts/lib/iso.mjs +58 -0
- package/plugin/skills/lit-schematic/scripts/lib/legend-sheet.mjs +10 -0
- package/plugin/skills/lit-schematic/scripts/lib/qa-core.mjs +241 -0
- package/plugin/skills/lit-schematic/scripts/lib/sheet.mjs +446 -0
- package/plugin/skills/lit-schematic/scripts/lib/svg.mjs +39 -0
- package/plugin/skills/lit-schematic/scripts/lib/theme.mjs +25 -0
- package/plugin/skills/lit-schematic/scripts/motion.mjs +8 -0
- package/plugin/skills/lit-schematic/scripts/qa.mjs +21 -0
- package/plugin/skills/lit-schematic/scripts/render.mjs +38 -0
- package/plugin/skills/lit-schematic/scripts/scaffold.mjs +73 -0
- package/plugin/skills/lit-scientific-visualization/SKILL.md +2 -0
- package/plugin/skills/lit-team/scripts/state-root.mjs +5 -18
- package/plugin/skills/lit-typographic-motion/SKILL.md +12 -8
- package/plugin/skills/lit-typographic-motion/engine/THIRD_PARTY_NOTICES +11 -0
- package/plugin/skills/lit-typographic-motion/engine/blender/runner.py +218 -0
- package/plugin/skills/lit-typographic-motion/engine/core/presets.mjs +1 -1
- package/plugin/skills/lit-typographic-motion/engine/node/assets.mjs +218 -0
- package/plugin/skills/lit-typographic-motion/engine/node/blender.mjs +144 -0
- package/plugin/skills/lit-typographic-motion/engine/node/board.mjs +136 -0
- package/plugin/skills/lit-typographic-motion/engine/node/done.mjs +42 -3
- package/plugin/skills/lit-typographic-motion/engine/node/gallery.mjs +120 -0
- package/plugin/skills/lit-typographic-motion/engine/node/house.mjs +44 -0
- package/plugin/skills/lit-typographic-motion/engine/node/pipeline.mjs +18 -7
- package/plugin/skills/lit-typographic-motion/engine/node/runtime.mjs +6 -3
- package/plugin/skills/lit-typographic-motion/engine/node/scaffold.mjs +210 -0
- package/plugin/skills/lit-typographic-motion/engine/node/score.mjs +639 -0
- package/plugin/skills/lit-typographic-motion/engine/node/sound-cli.mjs +21 -4
- package/plugin/skills/lit-typographic-motion/engine/node/sound.mjs +126 -27
- package/plugin/skills/lit-typographic-motion/engine/node/stage-serve.mjs +29 -5
- package/plugin/skills/lit-typographic-motion/engine/node/stage.mjs +82 -14
- package/plugin/skills/lit-typographic-motion/engine/node/styles.mjs +214 -0
- package/plugin/skills/lit-typographic-motion/engine/node/treatment.mjs +311 -10
- package/plugin/skills/lit-typographic-motion/engine/vendor/three/LICENSE +21 -0
- package/plugin/skills/lit-typographic-motion/engine/vendor/three/addons/environments/RoomEnvironment.js +144 -0
- package/plugin/skills/lit-typographic-motion/engine/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
- package/plugin/skills/lit-typographic-motion/engine/vendor/three/three.module.js +54571 -0
- package/plugin/skills/lit-typographic-motion/recipes/hero-3d/expected.json +69 -0
- package/plugin/skills/lit-typographic-motion/recipes/hero-3d/index.html +139 -0
- package/plugin/skills/lit-typographic-motion/references/depth.md +122 -0
- package/plugin/skills/lit-typographic-motion/references/sound.md +74 -16
- package/plugin/skills/lit-typographic-motion/references/stage.md +29 -5
- package/plugin/skills/lit-typographic-motion/references/studio.md +245 -0
- package/plugin/skills/lit-typographic-motion/references/treatment.md +31 -8
- package/plugin/skills/lit-typographic-motion/scripts/motion.mjs +48 -7
- package/plugin/skills/lit-typographic-motion/styles/paper.json +41 -0
- package/plugin/skills/lit-typographic-motion/styles/swiss-signal.json +41 -0
- package/plugin/skills/lit-typographic-motion/styles/terminalcore.json +42 -0
- package/plugin/skills/lit-typographic-motion/styles/tidal.json +41 -0
- package/plugin/skills/readme-studio/SKILL.md +1 -1
- package/plugin/skills/start-work/SKILL.md +10 -1
package/plugin/commands/lit.md
CHANGED
|
@@ -6,7 +6,7 @@ LitMuse route request: $ARGUMENTS
|
|
|
6
6
|
|
|
7
7
|
Treat the line above as a LitMuse request; the prompt hook has already recorded its route.
|
|
8
8
|
|
|
9
|
-
1. The first word picks the route: loop → lit-loop, plan → lit-plan, work → litwork, goal → litgoal, research → litresearch, review → review-work, start → start-work, recap → lit-recap, status → lit-recap, handoff → lit-handoff, init → lit-init, debug → debugging, interview → deep-interview, crucible → lit-crucible, commit → lit-commit, refactor → refactor, burnoff → lit-burnoff, burnoff-file → lit-burnoff-file, brainstorm → lit-brainstorm, brainstorming → lit-brainstorm, ideation → lit-brainstorm, ideate → lit-brainstorm, idea → lit-brainstorm, ideas → lit-brainstorm, team → lit-team, conference → autoconference, rules → rules, lsp → lsp, diagnostics → lsp, lsp-setup → lsp-setup, motion → lit-typographic-motion, film → lit-typographic-motion, video → lit-typographic-motion, 영상 → lit-typographic-motion, explain → lit-comprehend, humanize → lit-humanizer, korean → lit-humanizer, 다듬어 → lit-humanizer, 윤문 → lit-humanizer. Any other first word means lit-loop, except a task that asks to make a film (a film compound such as kinetic type, lyric video, title sequence, cover loop, 모션그래픽, 가사 영상, or a bare video noun with a making verb), which goes to lit-typographic-motion. Skills without a keyword (frontend-ui-ux, readme-studio, visual-qa, comment-checker, wikify, autoresearch, structural-search, browser-drive, lit-code) are reached through lit-loop or their own `/<skill>` command.
|
|
9
|
+
1. The first word picks the route: loop → lit-loop, plan → lit-plan, work → litwork, goal → litgoal, research → litresearch, review → review-work, start → start-work, recap → lit-recap, status → lit-recap, handoff → lit-handoff, init → lit-init, debug → debugging, interview → deep-interview, crucible → lit-crucible, commit → lit-commit, refactor → refactor, burnoff → lit-burnoff, burnoff-file → lit-burnoff-file, brainstorm → lit-brainstorm, brainstorming → lit-brainstorm, ideation → lit-brainstorm, ideate → lit-brainstorm, idea → lit-brainstorm, ideas → lit-brainstorm, hijack → lit-hijack, hijacking → lit-hijack, 하이재킹 → lit-hijack, 하이잭 → lit-hijack, adhd → lit-adhd, rewind → lit-adhd, approve → start-work, deny → start-work, team → lit-team, conference → autoconference, rules → rules, lsp → lsp, diagnostics → lsp, lsp-setup → lsp-setup, motion → lit-typographic-motion, film → lit-typographic-motion, video → lit-typographic-motion, 영상 → lit-typographic-motion, explain → lit-comprehend, humanize → lit-humanizer, korean → lit-humanizer, 다듬어 → lit-humanizer, 윤문 → lit-humanizer. Any other first word means lit-loop, except a task that asks to make a film (a film compound such as kinetic type, lyric video, title sequence, cover loop, 모션그래픽, 가사 영상, or a bare video noun with a making verb), which goes to lit-typographic-motion. Skills without a keyword (frontend-ui-ux, readme-studio, visual-qa, comment-checker, wikify, autoresearch, structural-search, browser-drive, lit-code) are reached through lit-loop or their own `/<skill>` command.
|
|
10
10
|
2. Load that skill with read_skill using the id `plugin:litmuse:<route>` before any other tool, and follow it. When the hook context says the route is already active or resuming, skip steps 2 and 3: keep the criteria, plan and ledger you have.
|
|
11
11
|
3. Your first output, before any tool call or other text, is the line `🔥 **LIT IGNITED · <route>** 🔥` on its own line, once. When the hook context says the route continues, the line is `🔥 **LIT CONTINUES · <route>** 🔥` instead.
|
|
12
12
|
4. Report in reader mode: the result, any material risk, and the action the user must take.
|
|
@@ -10,7 +10,7 @@ const PLUGIN = join(LIB, '..', '..');
|
|
|
10
10
|
|
|
11
11
|
// Files outside hooks/lib that a hook loads at run time: PostToolUse runs the lit-humanizer detector, and Stop runs the
|
|
12
12
|
// lit-typographic-motion done-check with every engine module it imports.
|
|
13
|
-
const MOTION_DONE = ['node/done.mjs', 'node/cover-assets.mjs', 'core/constants.mjs', 'node/chrome.mjs', 'node/cover-image.mjs', 'node/cover-treatment.mjs', 'node/treatment.mjs', 'node/sound.mjs', 'core/util.mjs', 'node/look.mjs', 'node/stills.mjs', 'node/analysis.mjs', 'core/flash.mjs', 'node/png.mjs'];
|
|
13
|
+
const MOTION_DONE = ['node/done.mjs', 'node/cover-assets.mjs', 'core/constants.mjs', 'node/chrome.mjs', 'node/cover-image.mjs', 'node/cover-treatment.mjs', 'node/treatment.mjs', 'node/sound.mjs', 'node/house.mjs', 'node/styles.mjs', 'node/score.mjs', 'core/presets.mjs', 'core/util.mjs', 'node/look.mjs', 'node/stills.mjs', 'node/analysis.mjs', 'core/flash.mjs', 'node/png.mjs'];
|
|
14
14
|
export const DIGEST_EXTRAS = ['skills/lit-humanizer/scripts/core.mjs', 'skills/lit-humanizer/rules.json', ...MOTION_DONE.map((f) => `skills/lit-typographic-motion/engine/${f}`)];
|
|
15
15
|
|
|
16
16
|
const listed = (dir, ext) => readdirSync(dir).filter((f) => f.endsWith(ext)).sort().map((f) => join(dir, f));
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// The `go` half of the lit-adhd brief and of a saved lit-plan: the collector (or the Stop hook,
|
|
2
|
+
// for a plan) writes this session's next-step record under `.litmuse/adhd/`, and a later
|
|
3
|
+
// whole-prompt `go` reads it back and deletes it. The session
|
|
4
|
+
// binding is the file name, so another session's record is never opened. A step older than 30
|
|
5
|
+
// minutes is stale: the brief it came from no longer describes the work.
|
|
6
|
+
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeSync } from 'node:fs';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
import { workspaceOf } from './common.mjs';
|
|
10
|
+
import { resolveProjectStateRoot } from './workflow-stack.mjs';
|
|
11
|
+
|
|
12
|
+
// The whole prompt, nothing more: `go`, `lit go`, `ㄱㄱ`, `고` or `진행`, with trailing punctuation.
|
|
13
|
+
const GO_PROMPT = /^\s*(?:(?:lit\s+)?go|ㄱㄱ|고|진행)[\s.!?]*$/iu;
|
|
14
|
+
export const isGoPrompt = (prompt) => typeof prompt === 'string' && prompt.length <= 64 && GO_PROMPT.test(prompt);
|
|
15
|
+
|
|
16
|
+
export const NEXT_STEP_MAX_AGE_MS = 30 * 60_000;
|
|
17
|
+
const KINDS = new Set(['resume-frame', 'approve-boundary', 'continue-work', 'start-plan', 'none']);
|
|
18
|
+
|
|
19
|
+
const nextStepFile = (cwd, sessionId) => {
|
|
20
|
+
const id = String(sessionId ?? 'session').replace(/[^A-Za-z0-9_-]/gu, '_').slice(0, 128) || 'session';
|
|
21
|
+
return join(resolveProjectStateRoot(cwd), '.litmuse', 'adhd', `next-${id}.json`);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const PLAN_NAME = /^plans\/[A-Za-z0-9._-]+\.md$/u;
|
|
25
|
+
|
|
26
|
+
// `.litmuse` and `.litmuse/adhd` must be real folders (or not there yet): a linked one would carry
|
|
27
|
+
// the record out of the project.
|
|
28
|
+
const realDirs = (root, dir) => {
|
|
29
|
+
for (const part of [join(root, '.litmuse'), dir]) {
|
|
30
|
+
try {
|
|
31
|
+
if (!lstatSync(part).isDirectory()) return false;
|
|
32
|
+
} catch { /* absent: mkdir makes a real one */ }
|
|
33
|
+
}
|
|
34
|
+
mkdirSync(dir, { recursive: true });
|
|
35
|
+
return !lstatSync(join(root, '.litmuse')).isSymbolicLink() && !lstatSync(dir).isSymbolicLink();
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The lit-plan half of `go`: once a planning turn has saved `plans/<slug>.md`, records it as this
|
|
40
|
+
* session's `start-plan` step, so a whole-prompt `go` within 30 minutes starts start-work on it.
|
|
41
|
+
* Plan names outside `plans/<slug>.md` and linked state folders write nothing. Never throws.
|
|
42
|
+
*/
|
|
43
|
+
export const putNextStep = (payload, plan, now = Date.now()) => {
|
|
44
|
+
const sessionId = typeof payload?.session_id === 'string' && payload.session_id ? payload.session_id : null;
|
|
45
|
+
if (!sessionId || typeof plan !== 'string' || !PLAN_NAME.test(plan) || /[\r\n]/u.test(plan)) return null;
|
|
46
|
+
try {
|
|
47
|
+
const cwd = workspaceOf(payload);
|
|
48
|
+
const path = nextStepFile(cwd, sessionId);
|
|
49
|
+
if (!realDirs(resolveProjectStateRoot(cwd), dirname(path))) return null;
|
|
50
|
+
const staging = `${path}.${process.pid}.tmp`;
|
|
51
|
+
try {
|
|
52
|
+
unlinkSync(staging);
|
|
53
|
+
} catch { /* no leftover */ }
|
|
54
|
+
const fd = openSync(staging, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
|
|
55
|
+
try {
|
|
56
|
+
writeSync(fd, `${JSON.stringify({ kind: 'start-plan', target: { plan }, at: new Date(now).toISOString() })}\n`);
|
|
57
|
+
} finally {
|
|
58
|
+
closeSync(fd);
|
|
59
|
+
}
|
|
60
|
+
renameSync(staging, path);
|
|
61
|
+
return path;
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Reads and deletes this session's next-step record. Returns `{kind, target}` when the record is
|
|
69
|
+
* whole and at most 30 minutes old, otherwise null; a stale or unreadable record is deleted all
|
|
70
|
+
* the same. Never throws.
|
|
71
|
+
*/
|
|
72
|
+
export const takeNextStep = (payload, now = Date.now()) => {
|
|
73
|
+
const sessionId = typeof payload?.session_id === 'string' && payload.session_id ? payload.session_id : null;
|
|
74
|
+
if (!sessionId) return null;
|
|
75
|
+
let cwd = null;
|
|
76
|
+
try {
|
|
77
|
+
cwd = workspaceOf(payload);
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
if (!cwd) return null;
|
|
82
|
+
const path = nextStepFile(cwd, sessionId);
|
|
83
|
+
let text;
|
|
84
|
+
try {
|
|
85
|
+
const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
86
|
+
try {
|
|
87
|
+
const stat = fstatSync(fd);
|
|
88
|
+
if (!stat.isFile() || stat.size > 16_384) return null;
|
|
89
|
+
text = readFileSync(fd, 'utf8');
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
} finally {
|
|
93
|
+
try {
|
|
94
|
+
closeSync(fd);
|
|
95
|
+
} catch { /* already closed */ }
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
} finally {
|
|
100
|
+
try {
|
|
101
|
+
unlinkSync(path);
|
|
102
|
+
} catch { /* nothing to consume */ }
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const record = JSON.parse(text);
|
|
106
|
+
const age = now - Date.parse(record.at);
|
|
107
|
+
if (!KINDS.has(record.kind) || !(age >= -60_000 && age <= NEXT_STEP_MAX_AGE_MS)) return null;
|
|
108
|
+
return { kind: record.kind, target: record.target && typeof record.target === 'object' ? record.target : null };
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
@@ -25,6 +25,8 @@ export const SKILL_ROUTES = [
|
|
|
25
25
|
{ id: 'lit-humanizer', when: 'polish Korean or English prose into the writer\'s voice' },
|
|
26
26
|
{ id: 'lit-recap', when: 'where does the work stand (read-only)' },
|
|
27
27
|
{ id: 'lit-handoff', when: 'before stopping or switching sessions' },
|
|
28
|
+
{ id: 'lit-hijack', when: 'port published know-how into your own skill, clean-room' },
|
|
29
|
+
{ id: 'lit-adhd', when: 'brief every recent session: asked, did, waiting, roadmap, one next step' },
|
|
28
30
|
{ id: 'lit-init', when: 'orient in an unfamiliar repository; leaves AGENTS.md' },
|
|
29
31
|
{ id: 'frontend-ui-ux', when: 'build or restyle a user interface' },
|
|
30
32
|
{ id: 'readme-studio', when: 'write or refresh a README from repo facts' },
|
|
@@ -32,6 +34,7 @@ export const SKILL_ROUTES = [
|
|
|
32
34
|
{ id: 'lit-pptx', when: 'a slide deck (needs Python)' },
|
|
33
35
|
{ id: 'lit-scientific-visualization', when: 'a publication-ready figure from data' },
|
|
34
36
|
{ id: 'lit-diagram-drawer', when: 'an architecture, workflow or timeline diagram' },
|
|
37
|
+
{ id: 'lit-schematic', when: 'a P&ID, PFD, apparatus figure or isometric cutaway of real hardware' },
|
|
35
38
|
{ id: 'lit-typographic-motion', when: 'make a short film, kinetic type or lyric video, or a seamless README cover loop' },
|
|
36
39
|
{ id: 'autoresearch', when: 'a budgeted experiment loop against a metric' },
|
|
37
40
|
{ id: 'structural-search', when: 'find or rewrite code by syntax shape (ast-grep)' },
|
|
@@ -70,6 +73,14 @@ export const KEYWORD_ROUTES = {
|
|
|
70
73
|
idea: 'lit-brainstorm',
|
|
71
74
|
ideas: 'lit-brainstorm',
|
|
72
75
|
team: 'lit-team',
|
|
76
|
+
hijack: 'lit-hijack',
|
|
77
|
+
hijacking: 'lit-hijack',
|
|
78
|
+
'하이재킹': 'lit-hijack',
|
|
79
|
+
'하이잭': 'lit-hijack',
|
|
80
|
+
adhd: 'lit-adhd',
|
|
81
|
+
rewind: 'lit-adhd',
|
|
82
|
+
approve: 'start-work',
|
|
83
|
+
deny: 'start-work',
|
|
73
84
|
conference: 'autoconference',
|
|
74
85
|
rules: 'rules',
|
|
75
86
|
lsp: 'lsp',
|
|
@@ -102,6 +113,44 @@ export function motionIntent(task) {
|
|
|
102
113
|
return FILM_COMPOUND.test(text) || (VIDEO_NOUN.test(text) && MAKE_VERB.test(text));
|
|
103
114
|
}
|
|
104
115
|
|
|
116
|
+
// Hardware wording (P&ID, PFD, cutaway, isometric, apparatus, and the Korean plant-drawing
|
|
117
|
+
// words) routes a `lit` prompt to lit-schematic. Software-architecture wording keeps its
|
|
118
|
+
// existing route: the guard below screens those prompts out.
|
|
119
|
+
const SCHEMATIC_WORD = /P&ID|P&I\b|PFD|BFD|schematic|cutaway|isometric|apparatus|Piping and Instrumentation|모식|장치도|공정도|계장도|배관계장/i;
|
|
120
|
+
const NOT_A_SCHEMATIC = /codebase|auth flow|boxes and arrows|software|entity-relationship|org chart|timeline|swimlane|sequence diagram/i;
|
|
121
|
+
function schematicIntent(task) {
|
|
122
|
+
const t = String(task ?? '');
|
|
123
|
+
if (NOT_A_SCHEMATIC.test(t)) return false;
|
|
124
|
+
return SCHEMATIC_WORD.test(t);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// A deck asked for by slide wording routes a `lit` prompt to lit-pptx: a slide noun with a making
|
|
128
|
+
// verb, or a slide count beside a slide noun ("덱 10장", "a 10-slide deck") with no verb at all.
|
|
129
|
+
// Code documentation and photo retouching ("사진 10장 보정") stay where they were.
|
|
130
|
+
const SLIDE_NOUN = /\b(?:slides?|slide\s?deck|deck|presentation|pptx?|powerpoint|keynote)\b|발표|슬라이드|피피티|파워포인트|(?<![가-힣])덱(?![가-힣])|(?<![가-힣])덱을|(?<![가-힣])덱으로/i;
|
|
131
|
+
const MAKE_DECK = /\b(?:make|making|made|create|creating|write|writing|draft|drafting|prepare|preparing|build|building|generate|generating|produce|producing)\b|만들|만드|작성|써\s?줘|써\s?주|써서|써봐|쓰고|정리해|정리하|준비|초안|뽑아|짜\s?줘|구성해/i;
|
|
132
|
+
const SLIDE_COUNT = /\d+\s?장|\b\d+[\s-]?slides?\b/i;
|
|
133
|
+
const NOT_A_DECK = /readme|changelog|contributing|api\s?(?:문서|docs?\b|reference)|docstrings?|jsdoc|주석|\bdocs\/|\b(?:bug|test|coverage|error|crash|lint|issue|incident)\s+reports?\b|사진\s?\d+\s?장\s?보정|보정해|보정하/i;
|
|
134
|
+
function deckIntent(task) {
|
|
135
|
+
const t = String(task ?? '');
|
|
136
|
+
if (NOT_A_DECK.test(t)) return false;
|
|
137
|
+
// A presentation asked for as a video is a film, not a deck.
|
|
138
|
+
if (motionIntent(t)) return false;
|
|
139
|
+
return SLIDE_NOUN.test(t) && (MAKE_DECK.test(t) || SLIDE_COUNT.test(t));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// An English request to build, redesign or review a page or site routes to the frontend skill: a making
|
|
143
|
+
// verb beside an interface noun, a UI target named outright, or an artefacts review beside a page noun.
|
|
144
|
+
const UI_NOUN = /\b(?:pages?|sites?|websites?|landing(?:\s?pages?)?|homepages?|portfolios?|dashboards?|forms?|checkouts?|onboardings?|wizards?|modals?|tooltips?|popovers?|navbars?|footers?|heros?|carousels?|galleries?|grids?|tables?|cards?|buttons?|inputs?|dropdowns?|tabs?|accordions?|themes?|layouts?|wireframes?|mockups?|prototypes?|redesigns?|uis?|ux|frontends?|front[-\s]?ends?|css|stylesheets?|responsive|accessibility|a11y|viewports?|breakpoints?|components?|design[-\s]?systems?|styleguides?|tokens?|interfaces?)\b/i;
|
|
145
|
+
const UI_VERB = /\b(?:build|building|create|creating|make|makes|making|design|designing|redesign|redesigning|restyle|restyling|revamp|revamping|polish|polishing|harden|hardening|audit|auditing|review|reviewing|fix|fixing|improve|improving|modernize|modernizing|responsive)\b/i;
|
|
146
|
+
const UI_TARGET = /^[^]*\b(?:page|site|website|landing|ui|ux|frontend|front-end|stylesheet|redesign|mockup|prototype|wireframe|theme|layout|component|design-system|styleguide)\b[^]*/i;
|
|
147
|
+
function uiBuildIntent(task) {
|
|
148
|
+
const text = String(task ?? '');
|
|
149
|
+
if (!text.trim()) return false;
|
|
150
|
+
if (UI_VERB.test(text) && UI_NOUN.test(text)) return true;
|
|
151
|
+
return UI_TARGET.test(text);
|
|
152
|
+
}
|
|
153
|
+
|
|
105
154
|
// `lit new …` and `lit restart …` start over; `lit continue` and `lit resume` pick the work up where it stands.
|
|
106
155
|
const RESTART_WORDS = new Set(['new', 'restart']);
|
|
107
156
|
const CONTINUE_WORDS = new Set(['continue', 'resume']);
|
|
@@ -136,6 +185,12 @@ function detectLeading(prompt) {
|
|
|
136
185
|
const marker = slash ? null : /^LitMuse route request:/.exec(text);
|
|
137
186
|
if (slash) text = `lit ${text.slice(slash[0].length).trimStart()}`;
|
|
138
187
|
else if (marker) text = `lit ${text.slice(marker[0].length).split('\n')[0].trim()}`;
|
|
188
|
+
// A direct skill slash for the brief (`/lit-adhd`, `/litmuse:lit-rewind`) names the
|
|
189
|
+
// route the same way a bare `lit-adhd` prompt does; without this the hook records no
|
|
190
|
+
// route and the LIT card keeps its normal headline while the brief runs.
|
|
191
|
+
if (/^\/(?:litmuse:)?lit-(?:adhd|rewind)(?=$|\s)/.test(text)) {
|
|
192
|
+
return { route: 'lit-adhd', keyword: null, bare: false, resume: false, restart: false, trailing: false };
|
|
193
|
+
}
|
|
139
194
|
const m = /^([A-Za-z][A-Za-z-]*)(?=$|[\s:,.!?])/.exec(text);
|
|
140
195
|
if (!m) return null;
|
|
141
196
|
const token = m[1];
|
|
@@ -152,9 +207,13 @@ function detectLeading(prompt) {
|
|
|
152
207
|
const resume = !restart && CONTINUE_WORDS.has(word);
|
|
153
208
|
const bare = !restart && (word === '' || (resume && rest.slice(word.length).trim() === ''));
|
|
154
209
|
const route = KEYWORD_ROUTES[word];
|
|
210
|
+
if (!route && !bare && !resume && schematicIntent(rest)) return { route: 'lit-schematic', keyword: null, bare, resume, restart };
|
|
211
|
+
if (!route && !bare && !resume && deckIntent(rest)) return { route: 'lit-pptx', keyword: null, bare, resume, restart };
|
|
155
212
|
if (!route && !bare && !resume && motionIntent(rest)) return { route: 'lit-typographic-motion', keyword: null, bare, resume, restart };
|
|
213
|
+
if (!route && !bare && !resume && uiBuildIntent(rest)) return { route: 'frontend-ui-ux', keyword: null, bare, resume, restart };
|
|
156
214
|
return { route: route ?? 'lit-loop', keyword: route ? word : null, bare, resume, restart };
|
|
157
215
|
}
|
|
216
|
+
if (lower === 'lit-rewind') return { route: 'lit-adhd', keyword: null, bare: false, resume: false, restart: false, trailing: false };
|
|
158
217
|
if (lower.startsWith('lit') && SKILL_IDS.has(lower)) return { route: lower, keyword: null, bare: false, resume: false, restart: false };
|
|
159
218
|
return null;
|
|
160
219
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, mkdirSync, lstatSync, renameSync, realpathSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, lstatSync, renameSync, realpathSync, readdirSync } from 'node:fs';
|
|
2
2
|
import { join, resolve } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
@@ -66,8 +66,9 @@ export function readStage(file) {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
// mutate(record) returns the next record (or nothing to keep it). Never throws.
|
|
70
|
-
|
|
69
|
+
// mutate(record) returns the next record (or nothing to keep it). Never throws. `pointer: false` leaves
|
|
70
|
+
// `latest.json` alone, for writes that say nothing about which session the user is driving.
|
|
71
|
+
export function updateStage(payload, mutate, env = process.env, { pointer = true } = {}) {
|
|
71
72
|
try {
|
|
72
73
|
const cwd = workspaceOf(payload);
|
|
73
74
|
const file = stageFile(cwd, payload.session_id, env);
|
|
@@ -83,9 +84,10 @@ export function updateStage(payload, mutate, env = process.env) {
|
|
|
83
84
|
next.lanes = next.lanes.slice(-MAX_LANES);
|
|
84
85
|
for (const k of ['route', 'keyword', 'plan', 'kind']) if (k in next) next[k] = clip(next[k]);
|
|
85
86
|
if (!atomicWrite(file, next)) return false;
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
if (!pointer) return true;
|
|
88
|
+
const latest = readStage(latestFile(env)) ?? {};
|
|
89
|
+
latest[next.workspace] = { session: payload.session_id, updated: now };
|
|
90
|
+
atomicWrite(latestFile(env), latest);
|
|
89
91
|
return true;
|
|
90
92
|
} catch {
|
|
91
93
|
return false;
|
|
@@ -141,6 +143,51 @@ export function recordLaneStop(payload, env = process.env) {
|
|
|
141
143
|
}, env);
|
|
142
144
|
}
|
|
143
145
|
|
|
146
|
+
// Liveness: each session's last hook event as one small record, `stage/liveness/<session>.json`, apart from the
|
|
147
|
+
// stage record so a tool call never rewrites `latest.json`. PostToolUse and its failure twin say `alive`, Stop says
|
|
148
|
+
// `idle`, SessionEnd says `ended`. The card reads these to sort other sessions into running, idle and gone. A
|
|
149
|
+
// repeat of the same state inside 30 s writes nothing, so a busy session costs one write per half minute.
|
|
150
|
+
export const LIVENESS_STATES = Object.freeze(['alive', 'idle', 'ended']);
|
|
151
|
+
const LIVENESS_REPEAT_MS = 30_000;
|
|
152
|
+
// A herdr pane id is short and plain (`w2B:p3`); anything else is dropped rather than stored.
|
|
153
|
+
const PANE_ID = /^[A-Za-z0-9][A-Za-z0-9:_-]{0,31}$/;
|
|
154
|
+
|
|
155
|
+
export function livenessDir(env = process.env) {
|
|
156
|
+
return join(stageRoot(env), 'liveness');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function readLiveness(env = process.env) {
|
|
160
|
+
try {
|
|
161
|
+
return readdirSync(livenessDir(env)).filter((n) => n.endsWith('.json')).map((n) => readStage(join(livenessDir(env), n))).filter(Boolean);
|
|
162
|
+
} catch {
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function markLiveness(payload, state, { env = process.env, now = Date.now() } = {}) {
|
|
168
|
+
try {
|
|
169
|
+
const id = safeId(payload?.session_id);
|
|
170
|
+
if (!id || !LIVENESS_STATES.includes(state)) return false;
|
|
171
|
+
const root = stageRoot(env);
|
|
172
|
+
const dir = livenessDir(env);
|
|
173
|
+
if (isLink(root) || !safeDir(dir)) return false;
|
|
174
|
+
const file = join(dir, `${id}.json`);
|
|
175
|
+
const prev = isLink(file) ? null : readStage(file);
|
|
176
|
+
if (prev && prev.state === state && now - Date.parse(prev.at) < LIVENESS_REPEAT_MS) return false;
|
|
177
|
+
const cwd = workspaceOf(payload);
|
|
178
|
+
const pane = typeof env.HERDR_PANE_ID === 'string' && PANE_ID.test(env.HERDR_PANE_ID) ? env.HERDR_PANE_ID : null;
|
|
179
|
+
return atomicWrite(file, { version: 1, session: id, state, at: new Date(now).toISOString(), cwd, workspace: workspaceHash(cwd), pane });
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// The plugin version this session's hooks and skills were loaded from, for the card's restart chip.
|
|
186
|
+
export function recordLoadedVersion(payload, version, env = process.env) {
|
|
187
|
+
if (typeof version !== 'string' || !/^\d+\.\d+\.\d+/.test(version)) return false;
|
|
188
|
+
return updateStage(payload, (rec) => ({ ...rec, loadedVersion: version }), env, { pointer: false });
|
|
189
|
+
}
|
|
190
|
+
|
|
144
191
|
export function recordSessionEnd(payload, env = process.env) {
|
|
145
192
|
return updateStage(payload, (rec, now) => {
|
|
146
193
|
rec.route = null;
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// The session's workflow stack, `.litmuse/workflow-stack/<session>.json`: where each running
|
|
2
|
+
// discipline stands, so asking for something in the middle of a loop or a plan run pauses that
|
|
3
|
+
// work instead of erasing it. The bottom frame is the spine (the main line of work); a discipline
|
|
4
|
+
// started on top of it is a side frame. A successor in the main chain finishes the frame below it
|
|
5
|
+
// instead of pausing it. Asking for a skill already in the stack resumes its frame with its stage
|
|
6
|
+
// and count; `lit continue` on a side frame closes it and resumes the paused spine. Read-only
|
|
7
|
+
// disciplines never touch the stack. `applyStackEvent` is pure; the store below writes one small
|
|
8
|
+
// file per session, whole, and the status card reads it back through its own guarded read. Frames
|
|
9
|
+
// keep skills, stages and counts only; never a prompt, a command or a path other than a
|
|
10
|
+
// project-relative plan name.
|
|
11
|
+
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync, realpathSync, renameSync, writeFileSync, existsSync } from 'node:fs';
|
|
12
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
13
|
+
|
|
14
|
+
export const MAX_FRAMES = 3;
|
|
15
|
+
export const MAX_HISTORY = 20;
|
|
16
|
+
export const MAX_STACK_BYTES = 64 * 1024;
|
|
17
|
+
export const STACK_DROP_MS = 24 * 60 * 60 * 1000;
|
|
18
|
+
export const READ_ONLY_DISCIPLINES = Object.freeze(['lit-recap', 'lit-comprehend', 'lit-handoff', 'lit-adhd', 'lit-rewind']);
|
|
19
|
+
|
|
20
|
+
// Order in the main chain; a newcomer of a higher rank succeeds the frame below it.
|
|
21
|
+
const CHAIN_RANK = Object.freeze({
|
|
22
|
+
'deep-interview': 0,
|
|
23
|
+
'lit-brainstorm': 0,
|
|
24
|
+
'lit-crucible': 1,
|
|
25
|
+
'lit-plan': 1,
|
|
26
|
+
'start-work': 2,
|
|
27
|
+
litwork: 2,
|
|
28
|
+
litgoal: 2,
|
|
29
|
+
autoresearch: 2,
|
|
30
|
+
'lit-loop': 2,
|
|
31
|
+
'review-work': 3,
|
|
32
|
+
});
|
|
33
|
+
const MAX_VERDICTS = 8;
|
|
34
|
+
const PLAN_REF = /^plans\/[A-Za-z0-9_-][A-Za-z0-9._-]*\.md$/u;
|
|
35
|
+
|
|
36
|
+
// Where each LitMuse discipline enters the map. Skills without an entry start as side frames.
|
|
37
|
+
const DISCIPLINE_ENTRY = Object.freeze({
|
|
38
|
+
'deep-interview': { family: 'main', stage: 'interview' },
|
|
39
|
+
'lit-brainstorm': { family: 'main', stage: 'interview' },
|
|
40
|
+
'lit-crucible': { family: 'main', stage: 'plan' },
|
|
41
|
+
'lit-plan': { family: 'main', stage: 'plan' },
|
|
42
|
+
'start-work': { family: 'main', stage: 'work' },
|
|
43
|
+
litwork: { family: 'main', stage: 'work' },
|
|
44
|
+
litgoal: { family: 'main', stage: 'work' },
|
|
45
|
+
autoresearch: { family: 'main', stage: 'work' },
|
|
46
|
+
'lit-loop': { family: 'loop', stage: 'PIN' },
|
|
47
|
+
'review-work': { family: 'main', stage: 'review' },
|
|
48
|
+
litresearch: { family: 'research', stage: 'decompose' },
|
|
49
|
+
});
|
|
50
|
+
const WORKFLOW_FAMILIES = Object.freeze({
|
|
51
|
+
main: Object.freeze(['interview', 'plan', 'work', 'review', 'done']),
|
|
52
|
+
research: Object.freeze(['decompose', 'wave', 'expand', 'verify', 'synthesis']),
|
|
53
|
+
loop: Object.freeze(['PIN', 'RED', 'GREEN', 'VERIFY', 'REVIEW', 'RECORD']),
|
|
54
|
+
});
|
|
55
|
+
const STAGE_ALIASES = Object.freeze({ SURFACE: 'VERIFY', CLEAN: 'RECORD' });
|
|
56
|
+
export const REVIEW_LANES = Object.freeze([
|
|
57
|
+
Object.freeze({ id: 'goal', words: Object.freeze(['goal', 'scope', 'diff']) }),
|
|
58
|
+
Object.freeze({ id: 'quality', words: Object.freeze(['quality', 'package', 'payload']) }),
|
|
59
|
+
Object.freeze({ id: 'security', words: Object.freeze(['security', 'provenance']) }),
|
|
60
|
+
Object.freeze({ id: 'qa', words: Object.freeze(['qa', 'tests', 'evidence']) }),
|
|
61
|
+
Object.freeze({ id: 'context', words: Object.freeze(['context', 'surface', 'docs', 'readiness']) }),
|
|
62
|
+
]);
|
|
63
|
+
const STAGE_UNITS = Object.freeze(['task', 'criterion', 'iteration', 'wave', 'round']);
|
|
64
|
+
const VERDICT_STATES = Object.freeze(['pass', 'fail', 'pending', 'running']);
|
|
65
|
+
|
|
66
|
+
export const normalizeStage = (family, stage) => {
|
|
67
|
+
const stages = WORKFLOW_FAMILIES[family];
|
|
68
|
+
if (!stages) return null;
|
|
69
|
+
const raw = String(stage ?? '').trim();
|
|
70
|
+
const named = family === 'loop' ? STAGE_ALIASES[raw.toUpperCase()] ?? raw.toUpperCase() : raw.toLowerCase();
|
|
71
|
+
return stages.includes(named) ? named : null;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const emptyStack = (sessionId) => ({ version: 1, sessionId: typeof sessionId === 'string' && sessionId ? sessionId : null, frames: [], history: [] });
|
|
75
|
+
|
|
76
|
+
const countOf = (value) => (Number.isInteger(value) && value >= 0 && value <= 9999 ? value : undefined);
|
|
77
|
+
const planRef = (ref) => (ref && typeof ref.plan === 'string' && PLAN_REF.test(ref.plan) && !ref.plan.includes('..') ? { plan: ref.plan } : undefined);
|
|
78
|
+
const isSuccessor = (skill, frame) => CHAIN_RANK[skill] !== undefined && CHAIN_RANK[frame.skill] !== undefined && CHAIN_RANK[skill] > CHAIN_RANK[frame.skill];
|
|
79
|
+
|
|
80
|
+
const newFrame = (skill, ref, role, at) => {
|
|
81
|
+
const entry = DISCIPLINE_ENTRY[skill];
|
|
82
|
+
const frame = { skill, family: entry?.family ?? null, stage: entry?.stage ?? null, role, status: 'active', verdicts: {}, at };
|
|
83
|
+
const plan = planRef(ref);
|
|
84
|
+
if (plan) frame.ref = plan;
|
|
85
|
+
return frame;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// A saved plan, or a review whose five lanes have all reported: what comes next replaces it.
|
|
89
|
+
const isFinished = (frame) => frame.done === true
|
|
90
|
+
|| (frame.family === 'main' && frame.stage === 'review' && REVIEW_LANES.every(({ id }) => ['pass', 'fail'].includes(frame.verdicts?.[id])));
|
|
91
|
+
|
|
92
|
+
const counts = (frame) => ({ stage: frame.stage, n: frame.n, total: frame.total });
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Applies one event to the stack and returns the next stack with the transitions it made
|
|
96
|
+
* (`started`, `paused`, `resumed`, `side.done`, `finished`, `dropped`), which the caller may log.
|
|
97
|
+
* Events: `start {skill, ref?, restart?}`, `continue`, `move {family, stage, n?, total?, unit?,
|
|
98
|
+
* done?, verdicts?, ref?, scope?}` (the hooks' observations) and `record {...same fields}`.
|
|
99
|
+
*/
|
|
100
|
+
export const applyStackEvent = (stack, event, now = new Date()) => {
|
|
101
|
+
const at = now.toISOString();
|
|
102
|
+
const transitions = [];
|
|
103
|
+
const note = (type, frame) => transitions.push({ type, skill: frame.skill, ...counts(frame) });
|
|
104
|
+
let frames = (Array.isArray(stack?.frames) ? stack.frames : [])
|
|
105
|
+
.filter((frame) => now.getTime() - Date.parse(frame.at) <= STACK_DROP_MS)
|
|
106
|
+
.map((frame) => ({ ...frame }));
|
|
107
|
+
const pause = (frame) => {
|
|
108
|
+
if (frame.status === 'paused') return;
|
|
109
|
+
frame.status = 'paused';
|
|
110
|
+
frame.pausedAt = at;
|
|
111
|
+
note('paused', frame);
|
|
112
|
+
};
|
|
113
|
+
const activate = (frame) => {
|
|
114
|
+
const wasPaused = frame.status === 'paused';
|
|
115
|
+
frame.status = 'active';
|
|
116
|
+
frame.at = at;
|
|
117
|
+
delete frame.pausedAt;
|
|
118
|
+
if (wasPaused) note('resumed', frame);
|
|
119
|
+
};
|
|
120
|
+
const closeAbove = (index, type) => {
|
|
121
|
+
for (const frame of frames.slice(index + 1).reverse()) note(type, frame);
|
|
122
|
+
frames = frames.slice(0, index + 1);
|
|
123
|
+
};
|
|
124
|
+
const push = (frame) => {
|
|
125
|
+
frames.push(frame);
|
|
126
|
+
note('started', frame);
|
|
127
|
+
while (frames.length > MAX_FRAMES) {
|
|
128
|
+
const side = frames.findIndex((candidate, index) => index < frames.length - 1 && candidate.role === 'side');
|
|
129
|
+
const [dropped] = frames.splice(side === -1 ? 0 : side, 1);
|
|
130
|
+
note('dropped', dropped);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const start = (skill, ref, restart) => {
|
|
135
|
+
if (READ_ONLY_DISCIPLINES.includes(skill)) return;
|
|
136
|
+
const hasFamily = Boolean(DISCIPLINE_ENTRY[skill]);
|
|
137
|
+
if (restart) {
|
|
138
|
+
closeAbove(-1, 'finished');
|
|
139
|
+
push(newFrame(skill, ref, hasFamily ? 'spine' : 'side', at));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const existing = frames.findLastIndex((frame) => frame.skill === skill);
|
|
143
|
+
if (existing !== -1) {
|
|
144
|
+
const wanted = planRef(ref);
|
|
145
|
+
const kept = frames[existing].ref?.plan;
|
|
146
|
+
if (!isFinished(frames[existing]) && (!wanted || !kept || wanted.plan === kept)) {
|
|
147
|
+
closeAbove(existing, 'side.done');
|
|
148
|
+
activate(frames[existing]);
|
|
149
|
+
if (wanted) frames[existing].ref = wanted;
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const role = frames[existing].role;
|
|
153
|
+
closeAbove(existing - 1, 'finished');
|
|
154
|
+
if (frames.length) pause(frames.at(-1));
|
|
155
|
+
push(newFrame(skill, ref, role, at));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
let inherited = null;
|
|
159
|
+
let handed = null;
|
|
160
|
+
while (frames.length && isFinished(frames.at(-1))) {
|
|
161
|
+
const done = frames.pop();
|
|
162
|
+
note('finished', done);
|
|
163
|
+
inherited = done.role;
|
|
164
|
+
handed = handed ?? done.ref;
|
|
165
|
+
}
|
|
166
|
+
// Work that follows a plan in the main chain carries the plan's name on, so its count is the plan's.
|
|
167
|
+
const carried = (from) => ref ?? (CHAIN_RANK[skill] >= CHAIN_RANK['start-work'] ? from : undefined);
|
|
168
|
+
const top = frames.at(-1);
|
|
169
|
+
if (!top) {
|
|
170
|
+
push(newFrame(skill, carried(handed), hasFamily ? inherited ?? 'spine' : 'side', at));
|
|
171
|
+
} else if (!frames.some((frame) => frame.role === 'spine')) {
|
|
172
|
+
closeAbove(-1, 'finished');
|
|
173
|
+
push(newFrame(skill, ref, hasFamily ? 'spine' : 'side', at));
|
|
174
|
+
} else if (hasFamily && isSuccessor(skill, top)) {
|
|
175
|
+
frames.pop();
|
|
176
|
+
note(top.role === 'spine' ? 'finished' : 'side.done', top);
|
|
177
|
+
push(newFrame(skill, carried(top.ref ?? handed), top.role, at));
|
|
178
|
+
} else {
|
|
179
|
+
pause(top);
|
|
180
|
+
push(newFrame(skill, ref, 'side', at));
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const update = (frame, fields) => {
|
|
185
|
+
const stage = normalizeStage(fields.family, fields.stage) ? String(fields.stage).trim() : null;
|
|
186
|
+
if (!stage) return;
|
|
187
|
+
const named = fields.family === 'loop' ? stage.toUpperCase() : stage.toLowerCase();
|
|
188
|
+
const sameStage = frame.family === fields.family && frame.stage === named;
|
|
189
|
+
frame.family = fields.family;
|
|
190
|
+
frame.stage = named;
|
|
191
|
+
for (const key of ['n', 'total']) {
|
|
192
|
+
const value = countOf(fields[key]);
|
|
193
|
+
if (value !== undefined) frame[key] = value;
|
|
194
|
+
}
|
|
195
|
+
if (STAGE_UNITS.includes(fields.unit)) frame.unit = fields.unit;
|
|
196
|
+
if (typeof fields.done === 'boolean') frame.done = fields.done;
|
|
197
|
+
const plan = planRef(fields.ref);
|
|
198
|
+
if (plan) frame.ref = plan;
|
|
199
|
+
const given = Object.entries(fields.verdicts ?? {}).filter(([name, state]) => /^[a-z][a-z0-9-]{0,23}$/u.test(name) && VERDICT_STATES.includes(state));
|
|
200
|
+
frame.verdicts = Object.fromEntries([...Object.entries(sameStage ? frame.verdicts ?? {} : {}), ...given].slice(-MAX_VERDICTS));
|
|
201
|
+
frame.at = at;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
switch (event?.type) {
|
|
205
|
+
case 'start':
|
|
206
|
+
start(event.skill, event.ref, event.restart === true);
|
|
207
|
+
break;
|
|
208
|
+
case 'continue': {
|
|
209
|
+
const top = frames.at(-1);
|
|
210
|
+
const spine = frames.findLastIndex((frame) => frame.role === 'spine');
|
|
211
|
+
if (top?.role === 'side' && spine !== -1) {
|
|
212
|
+
closeAbove(spine, 'side.done');
|
|
213
|
+
activate(frames[spine]);
|
|
214
|
+
}
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
case 'move': {
|
|
218
|
+
const target = event.scope === 'nearest'
|
|
219
|
+
? frames.findLast((frame) => frame.family === event.family && frame.stage === event.stage)
|
|
220
|
+
: frames.at(-1);
|
|
221
|
+
if (target && target.family === event.family && (event.scope === 'nearest' || target.status === 'active')) update(target, event);
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
case 'record':
|
|
225
|
+
if (frames.length) update(frames.at(-1), event);
|
|
226
|
+
else if (normalizeStage(event.family, event.stage)) {
|
|
227
|
+
const frame = { skill: null, family: event.family, stage: null, role: 'spine', status: 'active', verdicts: {}, at };
|
|
228
|
+
update(frame, event);
|
|
229
|
+
push(frame);
|
|
230
|
+
}
|
|
231
|
+
break;
|
|
232
|
+
default:
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (frames.length && frames.at(-1).status !== 'active') activate(frames.at(-1));
|
|
237
|
+
const history = [...(Array.isArray(stack?.history) ? stack.history : []), ...transitions.map(({ type, skill }) => ({ event: type, skill, at }))].slice(-MAX_HISTORY);
|
|
238
|
+
return { stack: { version: 1, sessionId: stack?.sessionId ?? null, frames, history }, transitions };
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// State placement: the outermost git root owns `.litmuse`, so a nested package shares its
|
|
242
|
+
// parent's state; without git the workspace itself owns it. A linked worktree (its `.git` is a
|
|
243
|
+
// file) is its own root: the climb stops there, so its state never lands in the main checkout.
|
|
244
|
+
// Every reader and writer of `.litmuse` resolves through this one function.
|
|
245
|
+
export const resolveProjectStateRoot = (startDir = process.cwd()) => {
|
|
246
|
+
const start = resolve(startDir);
|
|
247
|
+
let root = null;
|
|
248
|
+
let current = start;
|
|
249
|
+
for (;;) {
|
|
250
|
+
const git = join(current, '.git');
|
|
251
|
+
if (existsSync(git)) {
|
|
252
|
+
root = current;
|
|
253
|
+
try {
|
|
254
|
+
if (!lstatSync(git).isDirectory()) return root;
|
|
255
|
+
} catch { /* vanished between checks: keep climbing */ }
|
|
256
|
+
}
|
|
257
|
+
const parent = dirname(current);
|
|
258
|
+
if (parent === current) return root ?? start;
|
|
259
|
+
current = parent;
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// The project a recorded cwd belongs to, for comparing records: links resolved first, so a session
|
|
264
|
+
// that reached the project through a symlinked path (`/var` vs `/private/var`) is the same project.
|
|
265
|
+
export const projectKey = (dir) => {
|
|
266
|
+
let real = resolve(dir);
|
|
267
|
+
try {
|
|
268
|
+
real = realpathSync(real);
|
|
269
|
+
} catch { /* gone or unreadable: compare as written */ }
|
|
270
|
+
return resolveProjectStateRoot(real);
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const fileName = (sessionId) => `${String(sessionId ?? 'session').replace(/[^A-Za-z0-9_-]/gu, '_').slice(0, 128) || 'session'}.json`;
|
|
274
|
+
|
|
275
|
+
export const stackPath = (cwd, sessionId) => join(resolveProjectStateRoot(cwd), '.litmuse', 'workflow-stack', fileName(sessionId));
|
|
276
|
+
|
|
277
|
+
const STATE_GITIGNORE = '# Created by LitMuse: local session state, not for commits.\n*\n';
|
|
278
|
+
|
|
279
|
+
// Every component from the project root down must be a real directory: lstat sees only the
|
|
280
|
+
// final component, so a `.litmuse` that is itself a link would otherwise smuggle writes elsewhere.
|
|
281
|
+
const stateDirOk = (root, dir) => {
|
|
282
|
+
const rel = dirname(resolve(dir)).replace(resolve(root), '');
|
|
283
|
+
const parts = [resolve(root), ...rel.split('/').filter(Boolean).map((_, i, all) => join(resolve(root), ...all.slice(0, i + 1)))];
|
|
284
|
+
for (const part of parts) {
|
|
285
|
+
try {
|
|
286
|
+
// Missing components are fine: mkdir creates them as real directories below.
|
|
287
|
+
if (lstatSync(part).isSymbolicLink()) return false;
|
|
288
|
+
} catch { /* not there yet */ }
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
mkdirSync(dir, { recursive: true });
|
|
292
|
+
} catch {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
return !lstatSync(dir).isSymbolicLink() && lstatSync(dir).isDirectory();
|
|
297
|
+
} catch {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
/** Reads the session's stack; a link, a non-file, an oversized or malformed file reads as empty. */
|
|
303
|
+
export const readStack = (cwd, sessionId) => {
|
|
304
|
+
const empty = emptyStack(sessionId);
|
|
305
|
+
let fd;
|
|
306
|
+
try {
|
|
307
|
+
const path = stackPath(cwd, sessionId);
|
|
308
|
+
if (!lstatSync(path).isFile()) return empty;
|
|
309
|
+
fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
310
|
+
const { size } = fstatSync(fd);
|
|
311
|
+
if (size > MAX_STACK_BYTES) return empty;
|
|
312
|
+
const buffer = Buffer.alloc(size);
|
|
313
|
+
readSync(fd, buffer, 0, size, 0);
|
|
314
|
+
const parsed = JSON.parse(buffer.toString('utf8'));
|
|
315
|
+
if (!parsed || !Array.isArray(parsed.frames) || parsed.frames.length > MAX_FRAMES) return empty;
|
|
316
|
+
return { ...empty, frames: parsed.frames, history: Array.isArray(parsed.history) ? parsed.history.slice(-MAX_HISTORY) : [] };
|
|
317
|
+
} catch {
|
|
318
|
+
return empty;
|
|
319
|
+
} finally {
|
|
320
|
+
if (fd !== undefined) closeSync(fd);
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
/** Writes the whole stack through a temporary file; a link at the destination is replaced, never followed. */
|
|
325
|
+
export const writeStack = (cwd, sessionId, stack) => {
|
|
326
|
+
const root = resolveProjectStateRoot(cwd);
|
|
327
|
+
const path = stackPath(cwd, sessionId);
|
|
328
|
+
if (!stateDirOk(root, dirname(path))) return stack;
|
|
329
|
+
try {
|
|
330
|
+
writeFileSync(join(dirname(dirname(path)), '.gitignore'), STATE_GITIGNORE, { flag: 'wx' });
|
|
331
|
+
} catch { /* an existing ignore file stays as it is */ }
|
|
332
|
+
const staging = `${path}.${process.pid}.tmp`;
|
|
333
|
+
writeFileSync(staging, `${JSON.stringify(stack)}\n`, { flag: 'w' });
|
|
334
|
+
renameSync(staging, path);
|
|
335
|
+
return stack;
|
|
336
|
+
};
|