Konubinix' opinionated web of thoughts

An Agent-Shell Workspace

Table of Contents
authorship
this note is edited using Claude/default, governed by the note how to do literate programming
tl;dr
an org document, one heading per thing the user and the writer put to each other, stops them waiting on each other — and stops the user reading prose they never asked for.
rule
nothing here reaches into agent-shell or tracking by advice while a hook, an event or a keymap will do.
  • why: an advice is a last resort, and a feature that cannot be had without one is a feature to ask about before it is written.

the stories: they hand work to each other, neither waits

why
a document carrying the state of each question lets them work apart, which the user hopes makes the work efficient.
what
what either of them puts to the other lives in a document rather than in the chat, so each works on something else and picks it up on getting there.
what
each of them is a heading, so the ball changes hands per heading rather than per conversation.
what
yes or no to a question of the writer’s is one keystroke.
what
none of the promises below is measured, by the user’s decision.

playing a review with it

what
a writer finishes a branch and has six things it wants judged.
what
it writes them as six headings, each anchored at the line it is about.
what
the user walks them, answering as they go, while it fixes what was answered first.
what
the last heading leaves the user’s hands, and there is nothing left for them in the file.

the promise this story asserts

what
the promise is that the user never waits on the writer and it never waits on them.
what
done means the file showing, at a glance, whose turn each question is on.
what
what links the two is that the story hands six questions across and back with neither of them ever stopping, which is the whole of the promise.
therefore
a question’s state is the whole mechanism — everything else here exists to put a question in front of the user, or to carry their answer back.

when it runs ahead of the user

what
a question asks the user about something they have never heard of.
what
the user takes the question away and stops the writer where it stands.
  • why: because it means they need to sync.
what
it comes back with what it found and the single next question, and waits for them.

the promise this story asserts

what
the promise is that the user can stop the writer the moment it runs ahead of them, and it comes back at their pace.
what
done means the question gone and the writer stopped, in one keystroke.
what
what links the two is that the story stops the writer in the middle of its work and gets it back one step at a time, which is the whole of the promise.
scope
the keystroke holds the stopping, and that it comes back at the user’s pace rests on the writer doing as it is told.
scope
the story opens at the question the user cannot follow, the answering that leads up to it being measured by playing a review with it.

raising a subject of the user’s own

what
the user wants a subject worked on, and no question of the writer’s touches it.
what
they type it as a subject, with what they have to say under it.
what
the writer takes it up and comes back to them on it.

the promise this story asserts

what
the promise is that what the user wants worked on gets in, whether or not the writer thought to ask.
what
done means the subject and its body sitting there for the writer, and the writer told there is something to read.
what
what links the two is that the story puts in a subject the writer never asked for and it is there for the writer, which is the whole of the promise.
scope
what the keystroke holds is the subject landing and the telling; the taking up rests on the writer doing as it is told.
scope
the story opens on a workspace already in front of the user, the getting there being measured by playing a review with it.

asking what a thing meant

what
a question names a thing the user does not know.
what
they send it back and ask what it was supposed to mean.
what
the question comes again, carrying a link to a fact that explains it.
what
they follow the link and read the fact.

the promise this story asserts

what
the promise is that a thing the user does not know is put within reach of the question that used it.
what
done means the link standing in the question and the fact standing behind it.
what
what links the two is that the story asks, follows the link and reads, which is the whole of the promise.
scope
what the program holds is the fact standing there and the link reaching it; the writer answering that way rests on it doing as it is told.
scope
the story stops at the user’s reading, the answering that follows being measured by playing a review with it.

clearing out what has been settled

what
the user clears the settled questions out of the workspace, one at a time.
what
a fact those questions pointed at is left with no question reaching it.
what
the writer finds it still has work to do, and takes the fact back.

the promise this story asserts

what
the promise is that no fact stays in the workspace once it helps resolve no question.
  • why: a fact that helps resolve no question is useless.
what
done means the workspace holding the questions the user still has and the facts they still need.
what
what links the two is that the story clears the settled questions and the facts they carried go after them, which is the whole of the promise.
scope
what the program holds is keeping the writer’s turn alive while a fact stands unreached; the taking back rests on it doing as it is told.

seeing which one it is on

what
the writer takes a question and puts it in WORKING.
what
the user sees which one it is.

the promise this story asserts

what
the promise is that the workspace shows which question the writer is on.
what
done means one question standing in WORKING and the rest not.
what
what links the two is that the story puts a question there and the user reads it off the file, which is the whole of the promise.
scope
what the program holds is one question there at a time, and every other tool refused while none is.

the shape of a workspace

why
a writer left to its own devices writes findings for the user — three paragraphs telling them what the code does, which they already know, and which ask them nothing.
what
the writer puts two kinds of heading in it — a question, which asks the user something, and a fact, which carries no state and no question mark.
rule
a heading is one line with something on it, whichever kind it is.
  • rule: and a question that does not end in a question mark is refused.
  • rule: nor does anything else the writer sends run past one line, so nothing it writes can open a heading the user never agreed to.
rule
a body is bullets in the note’s own grammar, never prose, whichever it is.
  • rule: it has three hundred characters to spend, its bullets and its link captions counted together, and no bullet may pass a hundred and twenty.
    • why: given room, a writer fills it with padding, so the whole body shares one small budget.
    • rule: and every refusal for length says what the limits are for: finding what matters, saying it shorter, going to the point, and what will not go in them being, as a rule, not worth reading.
      • why: told only the number, the writer shortens words and keeps the padding, or shaves the one thing that carried information.
rule
and a heading rendered past eighty lines is refused, code shown counted in.
(defconst konix/agent-shell-workspace-limits-said
  (concat "These limits are what make you find what matters: one thing to a bullet,"
          " and only what the user has to know. Find shorter ways to say things, go"
          " to the point, do not linger. Drop what carries nothing rather than"
          " trimming what does. What will not go into them is, as a rule, not worth"
          " their reading.")
  "What a writer is told about the limits, whenever its words run past them.")

(defconst konix/agent-shell-workspace-bullet-max 120
  "Longest bullet a question may carry.")

(defconst konix/agent-shell-workspace-body-max 300
  "Longest body a question may carry, bullets and link captions together.")

(defconst konix/agent-shell-workspace-lines-max 80
  "Most lines a rendered question may take, code shown included.")

(defun konix/agent-shell-workspace--past-a-limit (what length max heading and-then)
  "Refuse WHAT, LENGTH long under HEADING, for passing MAX, saying AND-THEN last."
  (error "%s of %d chars under \"%s\", max %d. %s %s"
         what length heading max
         konix/agent-shell-workspace-limits-said and-then))

(defun konix/agent-shell-workspace--no-longer-than (heading rendered)
  "Refuse RENDERED, what HEADING comes to, for taking too many lines."
  (let ((lines (1+ (cl-count ?\n rendered))))
    (when (> lines konix/agent-shell-workspace-lines-max)
      (error "\"%s\" comes to %d lines, max %d — point at fewer places"
             heading lines konix/agent-shell-workspace-lines-max))))
how
a body reaches a tool as one string of JSON, since that is all MCP carries, and is read back into bullets before anything is asked of it.
(defun konix/mcp-server-decode-json-list (value)
  "Parse VALUE into a list.
A lone object, or a string that is not JSON at all, yields a one-element list."
  (cond
   ((null value) nil)
   ((not (stringp value)) (append value nil))
   (t
    (let ((parsed (condition-case nil
                      (json-parse-string value
                                         :object-type 'alist
                                         :array-type 'list)
                    (json-parse-error (list value)))))
      (if (and (consp (car parsed)) (not (consp (car (car parsed)))))
          (list parsed)
        parsed)))))

(defun konix/agent-shell-workspace--bullets (note)
  "Return NOTE, a question's body, as a list of bullets."
  (konix/mcp-server-decode-json-list note))
how
a body is refused unless every bullet opens on an intention word the notes already use, and the whole of it stays inside those bounds.
(defun konix/agent-shell-workspace--check-bullet (heading bullet)
  "Refuse BULLET under HEADING unless it is a short « intention :: text »."
  (when (> (length bullet) konix/agent-shell-workspace-bullet-max)
    (konix/agent-shell-workspace--past-a-limit
     "Bullet" (length bullet) konix/agent-shell-workspace-bullet-max heading
     (format "Here: %s" bullet)))
  (unless (string-match "\\`\\([^ ].*?\\) :: .+\\'" bullet)
    (error "Bullet under \"%s\" is not « intention :: text »: %s" heading bullet))
  (let ((intention (match-string 1 bullet)))
    (unless (assoc intention konix/note-intention-words)
      (error "Unknown intention \"%s\" under \"%s\" — one of: %s"
             intention heading
             (mapconcat #'car konix/note-intention-words ", ")))))

(defun konix/agent-shell-workspace--check-body (heading bullets captions)
  "Refuse a question's BULLETS and CAPTIONS under HEADING unless they stay short."
  (dolist (bullet bullets)
    (konix/agent-shell-workspace--check-bullet heading bullet))
  (dolist (caption captions)
    (when (string-match-p "\n" caption)
      (error "A caption is one line, or it opens a heading of its own: %S" caption))
    (when (> (length caption) konix/agent-shell-workspace-bullet-max)
      (konix/agent-shell-workspace--past-a-limit
       "Caption" (length caption) konix/agent-shell-workspace-bullet-max heading
       (format "Here: %s" caption))))
  (let ((total (apply #'+ 0 (mapcar #'length (append bullets captions)))))
    (when (> total konix/agent-shell-workspace-body-max)
      (konix/agent-shell-workspace--past-a-limit
       "Body" total konix/agent-shell-workspace-body-max heading
       (concat "Drop a bullet rather than an address: a web address goes in url"
               " and a place in file and line, neither counting here")))))

what a fact carries

what
it holds whatever the writer thinks the user may want to know.
  • why: the user hopes the limits mitigate the chance of its being prose they never asked for.
  • what: it is the user’s to read, not to answer.
  • how: the writer writes and edits one with a tool of its own, and may take one back.
  • rule: one whose heading asks something, or opens on a state, is refused, and so is a body past the limits.
rule
and a fact names places the way a question does, carrying their links and their hunks.
  • why: a writer telling the user where something is had nowhere to put the link, so it said « line 935 » in words, which is the one thing they will not follow.
  • therefore: it no longer has to invent a question to have somewhere to link from.
(defun konix/agent-shell-workspace--places-written (places diff)
  "Return PLACES as the lines of a heading, their hunks from DIFF."
  (mapconcat
   (lambda (place)
     (let ((hunk (and diff
                      (with-current-buffer diff
                        (konix/agent-shell-workspace--hunk-text
                         (car place) (cadr place))))))
       (concat
        (format "  - %s[[file+emacs:%s::%s][%s:%s]]\n"
                (if (caddr place) (concat (caddr place) " : ") "")
                (car place) (cadr place)
                (file-name-nondirectory (car place))
                (cadr place))
        (if hunk
            (concat konix/agent-shell-workspace-under-a-bullet
                    "#+begin_src diff\n"
                    (org-escape-code-in-string hunk)
                    konix/agent-shell-workspace-under-a-bullet
                    "#+end_src\n")
          (or (konix/agent-shell-workspace--picture-text (car place))
              (konix/agent-shell-workspace--source-text
               (car place) (cadr place))
              "")))))
   places))

(defun konix/agent-shell-workspace--render-fact (entry &optional diff revisions)
  "Return ENTRY as one Org fact, asking nothing and standing in no state.
The places it names carry their hunks, taken from DIFF, and REVISIONS says
which revision each heading already written was read against."
  (let* ((bullets (konix/agent-shell-workspace--bullets (alist-get 'note entry)))
         (places (konix/agent-shell-workspace--places entry))
         (addresses (konix/agent-shell-workspace--addresses entry))
         (heading (string-trim
                   (or (alist-get 'label entry)
                       (error "A label is what the fact is called — there is none"))))
         (id (or (alist-get 'id entry) (org-id-new)))
         (revspec (konix/agent-shell-workspace--revision-of entry id revisions)))
    (unless (string-match-p "\\`[^\n]+\\'" heading)
      (error "A heading is one line with something on it: %S" heading))
    (when (string-suffix-p "?" heading)
      (error "A fact asks nothing, so its heading cannot end in a question mark: %s"
             heading))
    (when (member (car (split-string heading))
                  konix/agent-shell-workspace-keywords)
      (error "A fact stands in no state, so its heading cannot open on a keyword: %s"
             heading))
    (konix/agent-shell-workspace--check-body
     heading bullets (delq nil (mapcar #'caddr places)))
    (konix/agent-shell-workspace--check-addresses heading addresses)
    (let ((fact (concat (format "* %s\n" heading)
                        "  :PROPERTIES:\n  :ID:       " id "\n"
                        (if revspec
                            (concat "  :REVSPEC:  " revspec "\n")
                          "")
                        "  :END:\n"
                        (mapconcat (lambda (bullet)
                                     (concat "  - " bullet "\n"))
                                   bullets)
                        (konix/agent-shell-workspace--addresses-written addresses)
                        (konix/agent-shell-workspace--places-written places diff)
                        "\n")))
      (konix/agent-shell-workspace--no-longer-than heading fact)
      fact)))
rule
and a fact can name the question it reports on, which then links to it.
  • how: the id decides: the fact it names is rewritten, a call naming none adds one, and an id naming nothing, or naming something that is not a fact, is refused.
  • why: a fact must be linked to a question, and nothing nudged the writer into a fact rather than a question that asks nothing.
  • how: the link goes at the end of what is written under that question, ahead of the places it names, and not a word else of it is touched.
    • rule: and one already there for the same thing is rewritten where it stands, so a fact written again is linked once, by the words it now carries.
  • therefore: reporting has a home: a fact about the question, reached by it, so it neither pads a question nor stands unreached.
  • rule: and the fact links back, so the pair is doubly linked.
    • why: going from a fact to the questions using it is what was missing, and it wants no engine of roam’s, only the link written both ways.
    • therefore: o walks it either way, and nothing has to search.
     (defun konix/agent-shell-workspace--body-end (limit)
       "Return where what is written under the heading at point ends, within LIMIT."
       (save-excursion
         (forward-line 1)
         (while (or (looking-at org-planning-line-re)
                    (and (looking-at org-drawer-regexp)
                         (re-search-forward org-property-end-re limit t)))
           (forward-line 1))
         (while (and (< (point) limit)
                     (not (looking-at "^\\*+ "))
                     (not (looking-at
                           konix/agent-shell-workspace-pointing-regexp)))
           (forward-line 1))
         (point)))

     (defun konix/agent-shell-workspace--link-under (here there label)
       "Put a link to THERE, called LABEL, under the heading HERE, changing nothing else.
     One to THERE already under HERE is rewritten where it stands, rather than joined by
     a second."
       (unless (konix/agent-shell-workspace--goto-id here)
         (error "No heading %s in the workspace" here))
       (let* ((limit (konix/agent-shell-workspace--question-end))
              (already (save-excursion
                         (when (search-forward (format "[[id:%s]" there) limit t)
                           (cons (line-beginning-position)
                                 (min (point-max) (1+ (line-end-position))))))))
         (when already
           (delete-region (car already) (cdr already)))
         (goto-char (if already
                        (car already)
                      (konix/agent-shell-workspace--body-end
                       (konix/agent-shell-workspace--question-end))))
         (insert (format "  - what :: [[id:%s][%s]]\n" there label))))

     (defun konix/mcp-server-set-workspace-fact
         (label &optional note id about file line says also url revspec)
       "Write one fact of the workspace, replacing the one ID names or adding it.

MCP Parameters:
  label - What the fact is called, ending in no question mark and opening on no state
  note - Optional JSON array of « intention :: text » bullets
  id - Optional id of the fact to rewrite, as the listing tool gives it
  about - Optional id of the question this reports on, which then links to it
  file - Optional absolute path of a place this fact points at
  line - Line in that file
  says - Optional caption for the link itself
  also - Optional JSON array of further {file, line, says} or {url, says}
  url - Optional web address, written out whole and counting against no limit
  revspec - Optional revision this fact is about, as git would take it"
       (mcp-server-lib-with-error-handling
        (let* ((its-id (or id (org-id-new)))
               (entry (list (cons 'label label) (cons 'note note) (cons 'id its-id)
                            (cons 'file file)
                            (cons 'line (if (stringp line)
                                            (string-to-number line)
                                          line))
                            (cons 'says says) (cons 'also also)
                            (cons 'url url) (cons 'revspec revspec)))
               added)
          (konix/agent-shell-workspace--edit
           (lambda ()
             (let* ((revspec (konix/agent-shell-workspace--revision-of
                              entry its-id
                              (konix/agent-shell-workspace--revisions)))
                    (diff (when (and revspec file)
                            (konix/agent-shell-workspace--render-diff
                             revspec
                             (delete-dups
                              (delq nil (mapcar
                                         #'car
                                         (konix/agent-shell-workspace--places entry))))
                             (or (konix/agent-shell-workspace--keyword "DIRECTORY")
                                 (file-name-directory file))
                             konix/agent-shell-workspace-working-diff-buffer)))
                    (fact (konix/agent-shell-workspace--render-fact
                           entry diff (konix/agent-shell-workspace--revisions))))
               (if (konix/agent-shell-workspace--goto-id id)
                   (progn
                     (unless (konix/agent-shell-workspace--fact-at-point-p)
                       (error "%s is not a fact — use set_workspace_question for a question"
                              id))
                     (delete-region (point)
                                    (konix/agent-shell-workspace--question-end)))
                 (when id
                   (error "No heading %s in the workspace" id))
                 (goto-char (point-max))
                 (setq added t))
               (insert fact)
               (when about
                 (let ((heading (save-excursion
                                  (when (and (konix/agent-shell-workspace--goto-id about)
                                             (looking-at
                                              (concat "^\\*+ +\\(?:[A-Z]+ +\\)?"
                                                      "\\(?:\\[#[A-Z]\\] +\\)?\\(.*\\)$")))
                                    (string-trim (match-string 1))))))
                   (konix/agent-shell-workspace--link-under about its-id label)
                   (when (and heading its-id)
                     (konix/agent-shell-workspace--link-under
                      its-id about heading)))))))
          (if added
              (format "Added the fact \"%s\"" label)
            (format "Rewrote %s" id)))))

whose turn it is

what
a state is a keyword, and it says whose the next move is and nothing else.
TODO   ──▸ REFINE   ──▸ WORKING ──▸ CLOSING  ──▸ DONE
writer     the user     writer      the user     nobody
    ◂── answering ─┘                  ◂── reopening ─┘
what
DONE is settled, the user’s alone to declare.
  • scope: theirs by agreement rather than by refusal — the writing tool takes DONE like any other keyword, and nothing stops the writer writing it.
what
and MAYBE is off to one side of all five: the user’s to give and to take back, and the writer’s to leave alone.
  • why: the user wants to say « those are for later » and have it stick.
  • rule: any state goes to MAYBE, and MAYBE comes back to TODO.
  • rule: the writer is refused it both ways — it may neither put a question off nor pick one back up.
    • therefore: it is the one state that is a refusal rather than an agreement, the whole point being that the writer cannot reach what is in it.
  • scope: it waits on nobody, so it neither stops the user stepping nor counts as work left to the writer.
  • what: m puts the question at point off, and on one already put off it takes it back to TODO.
    • how: point stays where it is, since putting several off in a row is what the key is for.
    • rule: and taking one back tells the writer, which putting one off never does.
      • why: it is work handed to it, and it sat there waiting to be told while the question was its own again.
      • therefore: the two directions are not one gesture: one gives work, the other takes it away, and only the giving is worth waking anybody for.
(defun konix/agent-shell-workspace--facts-in-view ()
  "Return the ids of the facts whose words the user has open."
  (let (found)
    (save-excursion
      (goto-char (point-min))
      (unless (org-at-heading-p)
        (outline-next-heading))
      (while (org-at-heading-p)
        (when-let ((id (and (konix/agent-shell-workspace--fact-at-point-p)
                            (org-entry-get nil "ID"))))
          (save-excursion
            (org-end-of-meta-data t)
            (when (and (not (eobp))
                       (not (org-at-heading-p))
                       (not (invisible-p (point))))
              (push id found))))
        (outline-next-heading)))
    found))

(defun konix/agent-shell-workspace--in-view-again (ids)
  "Show the words of each heading IDS names again, unless the user has read it."
  (save-excursion
    (dolist (id ids)
      (when (and (konix/agent-shell-workspace--goto-id id)
                 (not (konix/agent-shell-workspace--read-p)))
        (konix/agent-shell-workspace--show-its-words)))))

(defvar-local konix/agent-shell-workspace--reading nil
  "Ids of the facts the user has open, which folding leaves open.")

(defun konix/agent-shell-workspace--cut-if-done ()
  "Cut the writer this workspace talks to, where nothing there is its own."
  (when (buffer-live-p konix/agent-shell-workspace--writer-buffer)
    (konix/agent-shell-workspace--cut-short
     konix/agent-shell-workspace--writer-buffer)))

(defmacro konix/agent-shell-workspace--write (&rest body)
  "Change the workspace by running BODY on it shown whole, then save and fold it."
  (declare (indent 0) (debug t))
  `(let ((inhibit-read-only t))
     (setq-local konix/agent-shell-workspace--reading
                 (konix/agent-shell-workspace--facts-in-view))
     (org-fold-show-all)
     ,@body
     (save-buffer)
     (konix/agent-shell-workspace-focus-question)
     (konix/agent-shell-workspace--cut-if-done)))

(defun konix/agent-shell-workspace-put-off ()
  "Put the question at point off, or take back one already put off and say so."
  (interactive)
  (save-excursion
    (konix/agent-shell-workspace--goto-question)
    (unless (org-get-todo-state)
      (user-error "A fact stands in no state, so there is nothing here to put off"))
    (let ((later (equal (org-get-todo-state)
                        konix/agent-shell-workspace-later-keyword)))
      (konix/agent-shell-workspace--write
        (org-todo (if later
                      konix/agent-shell-workspace-fresh-keyword
                    konix/agent-shell-workspace-later-keyword)))
      (when later
        (konix/agent-shell-workspace--submit
         (konix/agent-shell-workspace--target-writer))))))
(defconst konix/agent-shell-workspace-fresh-keyword "TODO"
  "Keyword a question the writer has not begun opens on.")

(defconst konix/agent-shell-workspace-refine-keyword "REFINE"
  "Keyword a question waiting on the user's answer opens on.")

(defconst konix/agent-shell-workspace-working-keyword "WORKING"
  "Keyword a question the writer is on opens on.")

(defconst konix/agent-shell-workspace-closing-keyword "CLOSING"
  "Keyword a question waiting on the user to agree it is finished opens on.")

(defconst konix/agent-shell-workspace-done-keyword "DONE"
  "Keyword a settled question opens on.")

(defconst konix/agent-shell-workspace-later-keyword "MAYBE"
  "Keyword a question the user has put off opens on.")

(defconst konix/agent-shell-workspace-users-keywords
  (list konix/agent-shell-workspace-refine-keyword
        konix/agent-shell-workspace-closing-keyword)
  "Keywords a question waiting on the user opens on.")

(defconst konix/agent-shell-workspace-writers-keywords
  (list konix/agent-shell-workspace-fresh-keyword
        konix/agent-shell-workspace-working-keyword)
  "Keywords a question waiting on the writer opens on.")

(defconst konix/agent-shell-workspace-open-keywords
  (list konix/agent-shell-workspace-fresh-keyword
        konix/agent-shell-workspace-refine-keyword
        konix/agent-shell-workspace-working-keyword
        konix/agent-shell-workspace-closing-keyword
        konix/agent-shell-workspace-later-keyword)
  "Keywords a question still open opens on, in the order it walks them.")

(defconst konix/agent-shell-workspace-keywords
  (append konix/agent-shell-workspace-open-keywords
          (list konix/agent-shell-workspace-done-keyword))
  "Keywords a question's heading can open on, the open ones and the closing one.")
how
three cheap questions are asked of the file over and over — which keyword a question opens on, whether it is the user’s, whether it is the writer’s — so each is one search.
rule
and looking for a keyword tells its case apart, the search being made to.
  • why: searching an Org buffer folds case by default, so a heading opening on the word « closing » read as a question the writer had closed.
  • scope: a heading is the user’s to word as they please, and only the six in capitals are states.
(defun konix/agent-shell-workspace--keyword-regexp (keywords)
  "Return a regexp matching a question's heading opening on one of KEYWORDS."
  (concat "^\\* \\(" (mapconcat #'identity keywords "\\|") "\\) "))

(defun konix/agent-shell-workspace--search-state (regexp &optional bound backwards)
  "Search for REGEXP, a heading's keyword, telling its case apart.
BOUND ends the search and BACKWARDS looks the other way."
  (let ((case-fold-search nil))
    (funcall (if backwards #'re-search-backward #'re-search-forward)
             regexp bound t)))

(defun konix/agent-shell-workspace--state-in (line)
  "Non-nil when LINE, a heading, opens on a keyword, its case told apart."
  (let ((case-fold-search nil))
    (string-match konix/agent-shell-workspace-state-regexp line)))

(defconst konix/agent-shell-workspace-state-regexp
  (konix/agent-shell-workspace--keyword-regexp
   konix/agent-shell-workspace-keywords)
  "Regexp matching a question's heading, its keyword captured.")

(defconst konix/agent-shell-workspace-users-regexp
  (konix/agent-shell-workspace--keyword-regexp
   konix/agent-shell-workspace-users-keywords)
  "Regexp matching a question waiting on the user: to answer, or to close.")

(defconst konix/agent-shell-workspace-writers-regexp
  (konix/agent-shell-workspace--keyword-regexp
   konix/agent-shell-workspace-writers-keywords)
  "Regexp matching a question the writer still owes work on.")

(defconst konix/agent-shell-workspace-settled-regexp
  (konix/agent-shell-workspace--keyword-regexp
   (list konix/agent-shell-workspace-done-keyword))
  "Regexp matching a question the user has settled.")
rule
each keyword wears its own face, and no two of the six share one.
  • how: named faces rather than colours, so the theme decides how they look.
  • rule: and none of them wears a face a heading wears.
    • why: the one waiting on the user read as a heading, so nothing set it apart.
  • scope: a keyword the user’s own configuration already faces keeps the face they gave it, and the rest of that configuration survives.
  • how: the colouring makes the highlighting read its rules afresh, not merely paint again.
    • why: the rules are worked out once, as a buffer starts highlighting, and a workspace made at binding was opened before it said what its keywords were.
    • therefore: one bound to a file that was not there wears its colours at once, where before it took a reading of the file from scratch.
(defconst konix/agent-shell-workspace-keyword-faces
  (list (cons konix/agent-shell-workspace-fresh-keyword
              'font-lock-comment-face)
        (cons konix/agent-shell-workspace-refine-keyword
              'error)
        (cons konix/agent-shell-workspace-working-keyword
              'font-lock-function-name-face)
        (cons konix/agent-shell-workspace-closing-keyword
              'font-lock-constant-face)
        (cons konix/agent-shell-workspace-done-keyword
              'font-lock-string-face)
        (cons konix/agent-shell-workspace-later-keyword
              'shadow))
  "Face each keyword wears where nothing else names them.")

(defun konix/agent-shell-workspace--colour-keywords ()
  "Face the keywords nothing else names, and refontify."
  (let ((configured (default-value 'org-todo-keyword-faces)))
    (setq-local org-todo-keyword-faces
                (append (seq-remove
                         (lambda (entry) (assoc (car entry) configured))
                         konix/agent-shell-workspace-keyword-faces)
                        configured)))
  (org-set-font-lock-defaults)
  (font-lock-refresh-defaults)
  (font-lock-flush))

which one to take first

what
a question may wear a priority after its keyword, org’s own cookie, running from [#A] down.
  • why: the writer keeps taking the ones written for later and leaving the urgent.
  • scope: the range is the user’s own, declared by the file, so it is whatever they already press elsewhere.
rule
the writer is shown, and may take up, only the questions of its own standing highest, the rest being none of its business until those stop being its.
  • why: weighing it was left to the writer, which read the cookie as part of the heading and took whatever came first regardless.
  • therefore: what it cannot see it cannot choose wrongly, so the order is kept by the arrangement rather than by the writer remembering.
  • how: how high a question stands is org’s own reckoning, so one wearing no cookie stands at whatever the file says the default is.
  • rule: and the run drops to the next standing of itself, the moment the last of the higher ones is the user’s or settled.
    • why: a higher one waiting on the user is not the writer’s, so holding the lower ones back for it would stop the work while nobody was doing it.
  • scope: the one it already holds is shown whatever it stands at, since it is what it is working on.
  • scope: the listing tool still gives the whole file back, the writer sometimes needing to see where a question of its own stands.
rule
and a rewrite keeps the one the user set.
  • what: the writer never writes a cookie, so a question carries only what the user put on it.
  • how: it is read back out of the file before the question is written over, the way its keyword is.
what
, sets it, on the question point stands in, and it is org’s own prompt.
  • how: standing on an answer it reaches the question above, the way answering does.
(defun konix/agent-shell-workspace-set-priority ()
  "Set the priority of the question at point, and save so the writer reads it."
  (interactive)
  (save-excursion
    (konix/agent-shell-workspace--goto-question)
    (konix/agent-shell-workspace--write
      (call-interactively #'org-priority))))
how
it is read the way the keyword is: one walk over the questions, asking org what each of them wears.
(defun konix/agent-shell-workspace--asked-of-each-question (question)
  "Return what QUESTION, run on each question of the workspace, says of it, by id."
  (let ((said (make-hash-table :test 'equal)))
    (save-excursion
      (goto-char (point-min))
      (unless (org-at-heading-p)
        (outline-next-heading))
      (while (org-at-heading-p)
        (let ((id (org-entry-get nil "ID")))
          (when (and id (org-get-todo-state))
            (when-let ((this (funcall question)))
              (puthash id this said))))
        (outline-next-heading)))
    said))

(defun konix/agent-shell-workspace--priorities ()
  "Return each question's priority cookie, keyed by its id."
  (konix/agent-shell-workspace--asked-of-each-question
   (lambda ()
     (when-let ((priority (org-element-property :priority
                                                (org-element-at-point))))
       (format "[#%c] " priority)))))

(defun konix/agent-shell-workspace--standing-at-point ()
  "Return how high the heading point stands on stands, as org reckons it."
  (org-get-priority (buffer-substring-no-properties
                     (line-beginning-position) (line-end-position))))

(defun konix/agent-shell-workspace--standing-highest ()
  "Return (HOW-HIGH . HEADING) for the highest the writer's own stand, or nil."
  (let (best)
    (save-excursion
      (goto-char (point-min))
      (while (konix/agent-shell-workspace--search-state
              konix/agent-shell-workspace-writers-regexp)
        (let ((how-high (konix/agent-shell-workspace--standing-at-point)))
          (when (or (null best) (> how-high (car best)))
            (setq best (cons how-high (org-get-heading t t t t)))))))
    best))

what the file says about itself

rule
whoever reads or writes a workspace does it in the buffer holding that file, the one the user reads, and nowhere else.
  • why: it is an Org buffer, so org answers what a heading is, which state it opens on and whose id it carries — none of which a copy read as text answers.
  • why: and a buffer of its own has no file, so org refuses to give a heading an id in it, and every reader had a second reading of the same file to keep true.
  • therefore: there is one place a workspace is, whoever is looking.
(defmacro konix/agent-shell-workspace--read-file (file &rest body)
  "Run BODY in the buffer holding FILE, leaving point where it stood."
  (declare (indent 1) (debug t))
  `(with-current-buffer (find-file-noselect ,file)
     (save-excursion ,@body)))
rule
and writing it shows it whole first, and saves it after.
  • why: writing into a folded workspace leaves folds org cannot make sense of, which is the same reason the user’s own keys go through one write.
(defmacro konix/agent-shell-workspace--write-file (file &rest body)
  "Run BODY in the buffer holding FILE, shown whole, and save it."
  (declare (indent 1) (debug t))
  `(konix/agent-shell-workspace--read-file ,file
     (let ((inhibit-read-only t))
       (org-fold-show-all)
       ,@body
       (save-buffer))))
rule
a file keyword the workspace needs is put back at the top on every rewrite, whatever the file had and wherever it had it.
(defun konix/agent-shell-workspace--goto-front-matter ()
  "Move where a file keyword belongs: after a leading property drawer."
  (goto-char (point-min))
  (when (looking-at "^:PROPERTIES:$")
    (when (re-search-forward "^:END:$" nil t)
      (forward-line 1))))

(defun konix/agent-shell-workspace--ensure-keyword (name value)
  "Make this buffer's NAME file keyword say VALUE, in the front matter."
  (save-excursion
    (goto-char (point-min))
    (while (re-search-forward (format "^#\\+%s:.*\n" name) nil t)
      (replace-match ""))
    (konix/agent-shell-workspace--goto-front-matter)
    (insert (format "#+%s: %s\n" name value))))
how
and what the file says about itself is read back out of it.
(defun konix/agent-shell-workspace--keyword (name)
  "Return the value of this buffer's NAME file keyword, or nil."
  (cadr (car (org-collect-keywords (list name)))))
rule
the file says it is a workspace, declares the six keywords and the range its priorities run over, and names the folded view it opens in, pictures shown.
  • how: opening one by hand keys on the first of those lines, the keywords telling no file from another.
  • how: the range is the one the user’s own configuration names, written into the file rather than assumed of whoever opens it.
  • rule: and org is made to read those lines again wherever they are written.
    • why: it works a file’s keywords out once, as the file is opened, and a workspace says what its own are after that.
    • therefore: the states are coloured from the moment one is bound, rather than from the first time the file is read afresh.
(defun konix/agent-shell-workspace--ensure-front-matter ()
  "Declare in this buffer what its own keywords have to say, and have org read it."
  (konix/agent-shell-workspace--ensure-keyword "WORKSPACE" "t")
  (konix/agent-shell-workspace--ensure-keyword
   "TODO" (format "%s | %s"
                  (mapconcat #'identity
                             konix/agent-shell-workspace-open-keywords " ")
                  konix/agent-shell-workspace-done-keyword))
  (konix/agent-shell-workspace--ensure-keyword
   "PRIORITIES" (format "%c %c %c"
                        (default-value 'org-highest-priority)
                        (default-value 'org-lowest-priority)
                        (default-value 'org-default-priority)))
  (konix/agent-shell-workspace--ensure-keyword
   "STARTUP" "overview linkpreviews")
  (org-set-regexps-and-options))
rule
and it names the session writing into it, as a link that resumes it.
  • how: it is the agent-shell: link org itself stores for a session, so following it lands where it would from anywhere else.
  • scope: it is the session that wrote last, and there is none while that session has no id yet.
(defun konix/agent-shell-workspace--ensure-session-link (shell)
  "Declare in this buffer the link resuming SHELL, when it has a session to link to."
  (when-let ((spec (konix/org-agent-shell--session-spec shell)))
    (konix/agent-shell-workspace--ensure-keyword
     "SESSION"
     (format "[[agent-shell:%s][%s]]"
             spec (konix/org-agent-shell--shell-label shell)))))

every heading addressable

what
a heading with no id can be neither pointed at nor removed by the writer, so every one is given one on the way in.
  • why: because this is what makes good links.

  • how: it is org’s own giving of one, so where the drawer goes, and what already counts as an id, are org’s to say rather than ours.

    • why: putting the drawer where org expects it — under the heading, or under the planning line org may have slipped between the two — was thirty lines of ours saying what one call of org’s says.
(defun konix/agent-shell-workspace--ensure-ids ()
  "Give every heading of this buffer an id, keeping the one it already has."
  (org-map-entries #'org-id-get-create))

both at once, on the way in

rule
a workspace is made well formed before anything looks at it, whether it is being bound or rewritten.
(defun konix/agent-shell-workspace--ensure-well-formed ()
  "Make this buffer a workspace: its keywords said, its headings addressable."
  (konix/agent-shell-workspace--ensure-front-matter)
  (konix/agent-shell-workspace--ensure-ids))

what a rewrite keeps, and what it takes

why
the writer rewrites a question whenever it sharpens it, and a rewrite that reset the user’s answer would make answering pointless.
rule
a rewrite hands the question back to the user, and the writer has to say so explicitly to keep it on its side.
rule
and a question the user closed cannot be rewritten at all — the call is refused.
  • why: what the user closed made sense to them, and they will not have the writer turn it into something they never agreed to.
rule
a rewrite takes the user’s answers with the question, once the writer has read them.
  • why: a rewrite usually makes the questions obsolete, and the answers are for the writer to read, not for the user.
rule
and the wording a rewrite writes over stays under the question, among the answers, so the two read as the conversation they were.
  • why: the writer sharpens a question into another one, and the user is left answering a wording they can no longer see.
  • how: it goes in above the answers it drew — after the last wording kept there, everything under which was said since — so the order is the order it happened in.
  • rule: and each line of that conversation says whose it is, the wordings opening on writer: and the answers on me:.
    • why: they stand side by side under the one question, and a reader who cannot tell them apart is reading someone else’s words as their own.
what
the state a question stands in is read back out of the file before anything is written over it.
(defun konix/agent-shell-workspace--states ()
  "Return each question's keyword, keyed by its id."
  (konix/agent-shell-workspace--asked-of-each-question #'org-get-todo-state))

(defun konix/agent-shell-workspace--revisions ()
  "Return the revision each heading is read against, keyed by its id."
  (let ((said (make-hash-table :test 'equal)))
    (save-excursion
      (goto-char (point-min))
      (unless (org-at-heading-p)
        (outline-next-heading))
      (while (org-at-heading-p)
        (when-let* ((id (org-entry-get nil "ID"))
                    (revspec (org-entry-get nil "REVSPEC")))
          (puthash id revspec said))
        (outline-next-heading)))
    said))

(defun konix/agent-shell-workspace--revision-of (entry id revisions)
  "Return the revision ENTRY names, or the one ID was written with in REVISIONS."
  (let ((named (or (alist-get 'revspec entry)
                   (and revisions (gethash id revisions)))))
    (unless (or (null named) (string-empty-p (string-trim named)))
      (string-trim named))))

the question rendered

how
the id a question is given on the way in is generated once and kept through every rewrite.
     (defun konix/agent-shell-workspace--render-question (entry diff states
                                                               &optional cookies
                                                               revisions)
       "Return ENTRY as one Org question, its hunks taken from DIFF and STATES.
COOKIES carries the priority each question wears and REVISIONS the revision it
is read against, so a rewrite keeps them."
       (let* ((places (konix/agent-shell-workspace--places entry))
              (addresses (konix/agent-shell-workspace--addresses entry))
              (bullets (konix/agent-shell-workspace--bullets (alist-get 'note entry)))
              (heading (string-trim
                        (or (alist-get 'label entry)
                            (error "A label is what you are asking the user — there is none"))))
              (id (or (alist-get 'id entry) (org-id-new)))
              (revspec (konix/agent-shell-workspace--revision-of entry id revisions))
              (settled (list konix/agent-shell-workspace-done-keyword
                             konix/agent-shell-workspace-later-keyword))
              (state (or (alist-get 'keyword entry)
                         (car (member (gethash id states) settled))
                         konix/agent-shell-workspace-refine-keyword))
              (cookie (or (and cookies (gethash id cookies)) "")))
         (unless (member state konix/agent-shell-workspace-keywords)
           (error "Unknown keyword \"%s\" — one of: %s" state
                  (mapconcat #'identity konix/agent-shell-workspace-keywords ", ")))
         (unless (string-match-p "\\`[^\n]+\\'" heading)
           (error "A heading is one line with something on it: %S" heading))
         (unless (string-suffix-p "?" heading)
           (error "A heading has to end in a question mark, or it asks the user nothing: %s"
                  heading))
         (dolist (place places)
           (dolist (part (list (car place) (cadr place)))
             (when (and part (string-match-p "\n" (format "%s" part)))
               (error "A place is one line, or it opens a heading of its own: %S" part))))
         (konix/agent-shell-workspace--check-body
          heading bullets (delq nil (mapcar #'caddr places)))
         (konix/agent-shell-workspace--check-addresses heading addresses)
         (let ((question
                (concat (format "* %s %s%s\n" state cookie heading)
                        "  :PROPERTIES:\n  :ID:       " id "\n"
                        (if revspec (concat "  :REVSPEC:  " revspec "\n") "")
                        "  :END:\n"
                        (mapconcat (lambda (bullet) (concat "  - " bullet "\n")) bullets)
                        (konix/agent-shell-workspace--addresses-written addresses)
                        (konix/agent-shell-workspace--places-written places diff)
                        "\n")))
           (konix/agent-shell-workspace--no-longer-than heading question)
           question)))
rule
a question that runs past one line or asks nothing is refused before anything is written.
(unless (member state konix/agent-shell-workspace-keywords)
  (error "Unknown keyword \"%s\" — one of: %s" state
         (mapconcat #'identity konix/agent-shell-workspace-keywords ", ")))
(unless (string-match-p "\\`[^\n]+\\'" heading)
  (error "A heading is one line with something on it: %S" heading))
(unless (string-suffix-p "?" heading)
  (error "A heading has to end in a question mark, or it asks the user nothing: %s"
         heading))
(dolist (place places)
  (dolist (part (list (car place) (cadr place)))
    (when (and part (string-match-p "\n" (format "%s" part)))
      (error "A place is one line, or it opens a heading of its own: %S" part))))
(konix/agent-shell-workspace--check-body
 heading bullets (delq nil (mapcar #'caddr places)))
(konix/agent-shell-workspace--check-addresses heading addresses)
what
everything the user needs in order to answer sits in the question itself: whose turn it is, which question it is, and the code of every place it names.
rule
and the revision it is read against is written on it, where it is about a change.
  • why: questions are generally about separate commits, so a revision the whole workspace shared would be right for none of them.
  • how: a property of its own on the heading, which a rewrite keeps the way it keeps the id.
  • therefore: the hunk it carries and the key that opens the change both read against the one it names, and a question naming none is about a document rather than a change.
(concat (format "* %s %s%s\n" state cookie heading)
        "  :PROPERTIES:\n  :ID:       " id "\n"
        (if revspec (concat "  :REVSPEC:  " revspec "\n") "")
        "  :END:\n"
        (mapconcat (lambda (bullet) (concat "  - " bullet "\n")) bullets)
        (konix/agent-shell-workspace--addresses-written addresses)
        (konix/agent-shell-workspace--places-written places diff)
        "\n")
what
one question may be about several places at once, one of them leading.
  • scope: and a heading may name none at all, which a fact telling something rather than pointing somewhere does.
(defun konix/agent-shell-workspace--places (entry)
  "Return ENTRY's places as a list of (FILE LINE CAPTION), the ones it names."
  (seq-filter
   #'car
   (cons (list (alist-get 'file entry) (alist-get 'line entry)
               (alist-get 'says entry))
         (mapcar (lambda (place)
                   (list (alist-get 'file place) (alist-get 'line place)
                         (alist-get 'says place)))
                 (konix/mcp-server-decode-json-list
                  (alist-get 'also entry))))))
what
and an address on the web is named the same way, in a slot of its own, with the caption the writer gives it.
  • why: a writer had the address in front of it and wrote it without its scheme, shaving the eight characters that make it a link, because the only place it had for one was a bullet it was being refused for length.
  • rule: what it names is written out whole, and never counts against the body.
    • therefore: nothing an address is put in is something to shorten, so the instinct that cut it has nothing left to cut.
  • rule: and its caption is one line and no longer than a bullet, counting against nothing all the same.
  • rule: and the refusal for a body too long says it: cut words, never an address.
    • why: that refusal is the one place a writer trimming to fit is certain to read, and the writer asked for the rule to be said there.
  • rule: and it counts as saying nothing, the way a place does: a question carrying an address and no words is still handed over empty.
    • why: what a heading says and where it points are two different things, and the user cannot answer « what about it? ».
(defun konix/agent-shell-workspace--addresses (entry)
  "Return ENTRY's web addresses as a list of (URL . CAPTION), the ones it names."
  (seq-filter
   #'car
   (cons (cons (alist-get 'url entry) (alist-get 'says entry))
         (mapcar (lambda (also)
                   (cons (alist-get 'url also) (alist-get 'says also)))
                 (konix/mcp-server-decode-json-list
                  (alist-get 'also entry))))))

(defun konix/agent-shell-workspace--check-addresses (heading addresses)
  "Refuse ADDRESSES under HEADING whose caption runs long or over a line."
  (dolist (address addresses)
    (when-let* ((caption (cdr address)))
      (when (string-match-p "\n" caption)
        (error "A caption is one line, or it opens a heading of its own: %S"
               caption))
      (when (> (length caption) konix/agent-shell-workspace-bullet-max)
        (error "Caption of %d chars under \"%s\", max %d: %s"
               (length caption) heading
               konix/agent-shell-workspace-bullet-max caption)))))

(defun konix/agent-shell-workspace--addresses-written (addresses)
  "Return ADDRESSES as the lines of a heading, each whole and as a link."
  (mapconcat (lambda (address)
               (format "  - %s[[%s]]\n"
                       (if (cdr address) (concat (cdr address) " : ") "")
                       (car address)))
             addresses))

the code comes to the user

why
a question about a line the user has to go and open is a question the user answers badly, or postpones.
rule
every place a question names shows its code, right there.
what
what the revision under review changed, when it changed that line; otherwise the code as it stands, four lines either side.
rule
the code goes in escaped the way org escapes any source block, ,* for * and ,#+ for #+.
rule
and what opens and closes the block is indented under the bullet naming the place, the code inside it left where it stands.
  • why: flush left, the block belonged to no bullet, and the ones after it read as a list of their own.
  • why: and a diff is coloured off the first character of the line, so indenting the lines took the colours out.
rule
and a file that is not text shows nothing of itself, the link to it being all there is to say.
  • why: a question pointed at an image, and its bytes were written into the workspace as though they were lines to read.
  • how: the head of the file decides it, a byte no text carries being what tells them apart.
  • rule: unless it is a picture, which shows itself.
    • why: a question about a drawing is answered by looking at it, and the user was being asked to go and open it first.
    • how: it is org’s own showing of one: a plain link to the file, on a line of its own, and the file says it opens with its pictures shown.
    • scope: a picture Emacs can display; anything else it cannot is bytes like the rest.
(defconst konix/agent-shell-workspace-context-lines 4
  "Lines shown either side of a question whose line the revision left untouched.")

(defconst konix/agent-shell-workspace-sniffed-bytes 4096
  "How much of a file's head is read to tell text from bytes.")

(defun konix/agent-shell-workspace--bytes-p (file)
  "Non-nil when FILE holds bytes rather than text."
  (with-temp-buffer
    (insert-file-contents-literally
     file nil 0 konix/agent-shell-workspace-sniffed-bytes)
    (and (string-search "\0" (buffer-substring-no-properties
                              (point-min) (point-max)))
         t)))

(defconst konix/agent-shell-workspace-under-a-bullet "    "
  "Indentation of what belongs to a bullet the workspace writes.")

(defun konix/agent-shell-workspace--picture-text (file)
  "Return FILE as the link org shows a picture by, when it is one."
  (when (and (file-readable-p file)
             (image-supported-file-p file))
    (format "%s[[file:%s]]\n"
            konix/agent-shell-workspace-under-a-bullet file)))

(defun konix/agent-shell-workspace--source-text (file line)
  "Return FILE around LINE as an Org source block, numbered from its own lines."
  (when (and (file-readable-p file)
             (not (konix/agent-shell-workspace--bytes-p file)))
    (let ((from (max 1 (- line konix/agent-shell-workspace-context-lines)))
          (to (+ line konix/agent-shell-workspace-context-lines)))
      (with-temp-buffer
        (insert-file-contents file)
        (goto-char (point-min))
        (forward-line (1- from))
        (let ((start (point)))
          (forward-line (1+ (- to from)))
          (let ((text (buffer-substring-no-properties start (point))))
            (format "%s#+begin_src %s -n %d\n%s%s#+end_src\n"
                    konix/agent-shell-workspace-under-a-bullet
                    (konix/agent-shell-workspace--src-lang file)
                    from
                    (org-escape-code-in-string
                     (if (string-suffix-p "\n" text)
                         text
                       (concat text "\n")))
                    konix/agent-shell-workspace-under-a-bullet)))))))
how
in practice, a block’s language has to name a mode Emacs actually has, not the file’s extension.
  • example: ts is nobody’s mode.
(defun konix/agent-shell-workspace--src-lang (file)
  "Return the Org source language whose mode Emacs would open FILE with."
  (let ((mode (assoc-default file auto-mode-alist 'string-match)))
    (when (consp mode) (setq mode (car mode)))
    (if (and (symbolp mode)
             (string-suffix-p "-mode" (symbol-name mode)))
        (string-remove-suffix "-mode" (symbol-name mode))
      (or (file-name-extension file) "text"))))

finding the line in a diff

rule
the diff is produced on demand from the revision the question names.
  • why: a diff kept on disk goes stale or goes missing.
  • rule: and the one the user reads is a buffer of its own, the hunks a question is written with being read in another.
    • why: the writer writes question after question, each reading its own revision, and every one of them rewrote the diff the user was reading.
    • how: the one the writer reads in is named the way Emacs names what nobody is meant to open, so it sits out of the way.
(defconst konix/agent-shell-workspace-diff-buffer "*konix-workspace-diff*"
  "Buffer holding the diff the user opened, which nothing else rewrites.")

(defconst konix/agent-shell-workspace-working-diff-buffer
  " *konix-workspace-diff-working*"
  "Buffer the hunks a heading carries are read in, which nobody opens.")

(defun konix/agent-shell-workspace--render-diff (revspec paths directory &optional into)
  "Fill INTO with `git diff REVSPEC -- PATHS' from DIRECTORY.
INTO is the buffer the user opens unless another is named."
  (let ((args (append (list "diff" revspec)
                      (when paths (cons "--" paths))))
        (buffer (get-buffer-create
                 (or into konix/agent-shell-workspace-diff-buffer))))
    (with-current-buffer buffer
      (let ((inhibit-read-only t))
        (widen)
        (erase-buffer)
        (setq-local default-directory directory)
        (unless (zerop (apply #'call-process "git" nil t nil args))
          (error "git diff failed: %s" (buffer-string)))
        (when (= (point-min) (point-max))
          (error "Empty diff for %s" revspec))
        (diff-mode)
        (konix/agent-shell-workspace-diff-mode 1)
        (goto-char (point-min))))
    buffer))
what
a question names a line of a file, and the diff has to be opened where that line sits.
  • why: a workspace is for working on a document, so a question is always about some place in that document.
(defun konix/agent-shell-workspace-hunk-line-position (start line limit)
  "Return where new-side LINE sits in the hunk body following point.
START is the hunk's first new-side line and LIMIT bounds its body."
  (forward-line 1)
  (let ((current start)
        position)
    (while (and (not position)
                (< (point) limit)
                (memq (char-after) '(?\s ?+ ?-)))
      (cond
       ((eq (char-after) ?-) (forward-line 1))
       ((= current line) (setq position (point)))
       (t (setq current (1+ current))
          (forward-line 1))))
    position))
how
a caller that wants to land somewhere useful takes the file’s first hunk when no hunk covers the line, and a caller that would mislead by doing so asks for the exact one instead.
(defun konix/agent-shell-workspace-diff-position (file line &optional exact)
  "Return where FILE:LINE sits in the diff held by the current buffer.
Nil when FILE is absent from the diff."
  (save-excursion
    (goto-char (point-min))
    (let (file-start)
      (while (and (not file-start)
                  (re-search-forward "^\\+\\+\\+ b?/?\\(.+\\)$" nil t))
        (when (string-suffix-p (match-string 1) file)
          (setq file-start (match-beginning 0))))
      (when file-start
        (goto-char file-start)
        (forward-line 1)
        (let ((limit (or (save-excursion (re-search-forward "^\\+\\+\\+ " nil t))
                         (point-max)))
              covering first)
          (while (and (not covering)
                      (re-search-forward
                       "^@@ -[0-9,]+ \\+\\([0-9]+\\)\\(?:,\\([0-9]+\\)\\)? @@"
                       limit t))
            (let ((start (string-to-number (match-string 1)))
                  (count (if (match-string 2)
                             (string-to-number (match-string 2))
                           1)))
              (unless first (setq first (match-beginning 0)))
              (when (and (<= start line) (< line (+ start count)))
                (setq covering
                      (or (konix/agent-shell-workspace-hunk-line-position
                           start line limit)
                          (match-beginning 0))))))
          (or covering (unless exact first)))))))
what
a file’s diff holds many hunks, and only the one the line falls in is any use.
(let ((limit (or (save-excursion (re-search-forward "^\\+\\+\\+ " nil t))
                 (point-max)))
      covering first)
  (while (and (not covering)
              (re-search-forward
               "^@@ -[0-9,]+ \\+\\([0-9]+\\)\\(?:,\\([0-9]+\\)\\)? @@"
               limit t))
    (let ((start (string-to-number (match-string 1)))
          (count (if (match-string 2)
                     (string-to-number (match-string 2))
                   1)))
      (unless first (setq first (match-beginning 0)))
      (when (and (<= start line) (< line (+ start count)))
        (setq covering
              (or (konix/agent-shell-workspace-hunk-line-position
                   start line limit)
                  (match-beginning 0))))))
  (or covering (unless exact first)))
rule
what is shown of a hunk is a window around the line.
  • why: a hunk can be a whole new file while the question is about one line of it.
(defun konix/agent-shell-workspace--hunk-text (file line)
  "Return the text of the hunk covering FILE:LINE in this diff buffer.
Nil when the file is absent from the diff, or when no hunk reaches LINE."
  (let ((position (konix/agent-shell-workspace-diff-position file line t)))
    (when position
      (save-excursion
        (goto-char position)
        (beginning-of-line)
        (let (old-start new-start body-start)
          (save-excursion
            (unless (looking-at "^@@")
              (re-search-backward "^@@" nil t))
            (when (looking-at "^@@ -\\([0-9]+\\)[0-9,]* \\+\\([0-9]+\\)")
              (setq old-start (string-to-number (match-string 1))
                    new-start (string-to-number (match-string 2))
                    body-start (save-excursion (forward-line 1) (point)))))
          (let* ((anchor (max (point) (or body-start (point))))
                 (body-end (save-excursion
                             (goto-char anchor)
                             (forward-line 1)
                             (if (re-search-forward "^\\(@@\\|diff \\)" nil t)
                                 (line-beginning-position)
                               (point-max))))
                 (from (save-excursion
                         (goto-char anchor)
                         (forward-line (- konix/agent-shell-workspace-context-lines))
                         (max (point) (or body-start (point-min)))))
                 (to (save-excursion
                       (goto-char anchor)
                       (forward-line
                        (1+ konix/agent-shell-workspace-context-lines))
                       (min (point) body-end)))
                 (counted (lambda (start end)
                            (let ((n 0))
                              (save-excursion
                                (goto-char start)
                                (while (< (point) end)
                                  (unless (looking-at "^-") (setq n (1+ n)))
                                  (forward-line 1)))
                              n)))
                 (shown (funcall counted from to)))
            (concat (format "@@ -%d,%d +%d,%d @@\n"
                            (+ (or old-start 0) (funcall counted body-start from))
                            shown
                            (+ (or new-start 1) (funcall counted body-start from))
                            shown)
                    (buffer-substring-no-properties from to))))))))
what
the window carries a header of its own, saying which lines of the file it shows.
  • why: the hunk’s own header said the whole file for a file the change adds, so every window of it read as the same place: @@ -0,0 +1,171 @@ everywhere.
  • how: the lines of the window are counted the way the change counts them, so the header says where the first of them sits in the file.
(save-excursion
  (unless (looking-at "^@@")
    (re-search-backward "^@@" nil t))
  (when (looking-at "^@@ -\\([0-9]+\\)[0-9,]* \\+\\([0-9]+\\)")
    (setq old-start (string-to-number (match-string 1))
          new-start (string-to-number (match-string 2))
          body-start (save-excursion (forward-line 1) (point)))))
how
the window is four lines either side, clipped to the body so it never runs into the hunk that follows.
(let* ((anchor (max (point) (or body-start (point))))
       (body-end (save-excursion
                   (goto-char anchor)
                   (forward-line 1)
                   (if (re-search-forward "^\\(@@\\|diff \\)" nil t)
                       (line-beginning-position)
                     (point-max))))
       (from (save-excursion
               (goto-char anchor)
               (forward-line (- konix/agent-shell-workspace-context-lines))
               (max (point) (or body-start (point-min)))))
       (to (save-excursion
             (goto-char anchor)
             (forward-line
              (1+ konix/agent-shell-workspace-context-lines))
             (min (point) body-end)))
       (counted (lambda (start end)
                  (let ((n 0))
                    (save-excursion
                      (goto-char start)
                      (while (< (point) end)
                        (unless (looking-at "^-") (setq n (1+ n)))
                        (forward-line 1)))
                    n)))
       (shown (funcall counted from to)))
  (concat (format "@@ -%d,%d +%d,%d @@\n"
                  (+ (or old-start 0) (funcall counted body-start from))
                  shown
                  (+ (or new-start 1) (funcall counted body-start from))
                  shown)
          (buffer-substring-no-properties from to)))

the whole diff, when the window is not enough

what
the user gets the question; wanting more context, the user opens the diff, glances at it, and goes back to the question to answer.
what
so the whole change is a keystroke away from the question, and one back.
  • why: a diff opened from a question is a detour, not a destination.
(defvar-local konix/agent-shell-workspace--buffer nil
  "Workspace this diff was shown from.")

(defconst konix/agent-shell-workspace-diff-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map "q" #'konix/agent-shell-workspace-back)
    (define-key map "r" #'konix/agent-shell-workspace-answer)
    (define-key map "R" #'konix/agent-shell-workspace-answer)
    (define-key map "y" #'konix/agent-shell-workspace-answer-yes)
    (define-key map "n" #'konix/agent-shell-workspace-answer-no)
    (define-key map "?" #'konix/agent-shell-workspace-answer-what)
    (define-key map (kbd "M-RET")
                #'konix/agent-shell-workspace-raise-from-diff)
    map)
  "Keymap of `konix/agent-shell-workspace-diff-mode'.")

(define-minor-mode konix/agent-shell-workspace-diff-mode
  "Read the diff of a workspace question.

\\{konix/agent-shell-workspace-diff-mode-map}"
  :lighter " WorkspaceDiff"
  :keymap konix/agent-shell-workspace-diff-mode-map
  (setq buffer-read-only konix/agent-shell-workspace-diff-mode))

(defun konix/agent-shell-workspace-back ()
  "Go back to the workspace this diff was shown from."
  (interactive)
  (if (buffer-live-p konix/agent-shell-workspace--buffer)
      (pop-to-buffer konix/agent-shell-workspace--buffer)
    (quit-window)))
rule
and the keys that answer are the workspace’s own, bound here unchanged: r for what the user types, y and n for yes and no, ? for what it was supposed to mean.
  • why: the user opened the diff to be able to answer, so the answer is where the reading ends and not one key further on.
  • how: what they read as standing on comes back from the diff to the question first, so each of them answers there without knowing a diff exists.
    • therefore: a key is written once and both places press it, and C-h k on the diff names the same command as in the workspace.
  • scope: a workspace gone from under it says so and answers nothing.
what
and M-RET raises a subject about the line being read, in the workspace the diff was opened from.
  • why: what the user notices reading a diff is a subject, and going to find the file to raise it is the detour becoming a journey.
  • how: the place is the one the diff line stands for in the file itself, so the link opens the source and not the diff.
  • rule: and the line is quoted as it reads in the diff, its sign kept.
    • why: what the user saw is the line as the change shows it.
(defun konix/agent-shell-workspace--source-here ()
  "Return (FILE . LINE) for the file line the diff line at point stands for."
  (let (file line)
    (save-window-excursion
      (save-excursion
        (ignore-errors
          (diff-goto-source)
          (setq file (buffer-file-name)
                line (line-number-at-pos)))))
    (when (and file line) (cons file line))))

(defun konix/agent-shell-workspace-raise-from-diff (subject &optional body)
  "Raise SUBJECT, and BODY under it, about the line this diff line stands for."
  (declare (modes konix/agent-shell-workspace-diff-mode))
  (interactive (konix/agent-shell-workspace--read-heading-and-body "Subject: "))
  (let ((place (or (konix/agent-shell-workspace--source-here)
                   (user-error "This line stands for no line of a file")))
        (quoted (string-trim-right
                 (buffer-substring-no-properties (line-beginning-position)
                                                 (line-end-position)))))
    (konix/agent-shell-workspace-raise-here
     subject body (car place) (cdr place) quoted)))
how
d opens it at the place the question sits, so the glance starts where the question does.
  • rule: and what it shows is that question’s own hunk, not the whole change.
    • why: a question is about one place, and the rest of the change belongs to the questions about it.
    • how: the diff is read for that one file, and the view restricted to the hunk the place falls in, by diff-mode’s own means.
    • scope: widening is diff-mode’s own key, so the rest of the file is one press away.
rule
the revision a question is read against is the one written on it, the writer naming it as it writes the question.
  • why: the writer decided which change the question is about, so it is the one that knows.
  • scope: it is a revspec, so anything git takes — a range says as many revisions as the question is about at once.
rule
and a question naming none has the writer told to name it, the user never asked for it.
  • why: naming the revision is the writer’s job, and being asked to type one is the arrangement handing its work back to the user.
  • therefore: the key says what is missing and who is being told, and the next press has the diff.
(defconst konix/agent-shell-workspace-no-revision-guidance
  (concat "That question names no revision, so the user's key for the diff has"
          " nothing to show. Write it again with set_workspace_question, passing"
          " its id and revspec — the change it is about, a sha, HEAD~1,"
          " main..HEAD — and say nothing else about it.")
  "What a writer is told when the question the user is on names no revision.")

(defun konix/agent-shell-workspace--ask-for-the-revision ()
  "Tell the writer to name the revision the question at point is read against."
  (konix/agent-shell-workspace--submit
   (konix/agent-shell-workspace--target-writer)
   konix/agent-shell-workspace-no-revision-guidance t)
  (user-error "That question names no revision — the writer is being told to"))

(defun konix/agent-shell-workspace--revision-here ()
  "Return the revision the heading point stands in is read against, or nil."
  (save-excursion
    (konix/agent-shell-workspace--goto-question)
    (org-entry-get (point) "REVSPEC")))

(defun konix/agent-shell-workspace--show-diff-at-point (select)
  "Display what the question point sits in is about, of the change it names.
Selects its window when SELECT is non-nil."
  (let ((revspec (or (konix/agent-shell-workspace--revision-here)
                     (konix/agent-shell-workspace--ask-for-the-revision)))
        (workspace (current-buffer)))
    (pcase-let* ((`(,file . ,line) (konix/agent-shell-workspace--location-at-point))
                 (directory (or (konix/agent-shell-workspace--keyword "DIRECTORY")
                                (file-name-directory file)))
                 (buffer (konix/agent-shell-workspace--render-diff
                          revspec (list file) directory))
                 (position (with-current-buffer buffer
                             (setq-local
                              konix/agent-shell-workspace--buffer workspace)
                             (konix/agent-shell-workspace-diff-position file line))))
      (unless position
        (user-error "%s is not in the diff" (file-name-nondirectory file)))
      (with-current-buffer buffer
        (goto-char position)
        (diff-restrict-view))
      (let ((window (display-buffer buffer)))
        (with-selected-window window
          (goto-char position)
          (recenter 0))
        (when select (select-window window))))))

(defun konix/agent-shell-workspace-goto-diff ()
  "Show the change the question point sits in is about, at its own place."
  (interactive)
  (konix/agent-shell-workspace--show-diff-at-point t))

where the headings accrue

the workspace itself

what
the headings live in a workspace, a document of their own, which is never the thing being worked on.
rule
it is an Org file, and anything else is refused.
what
and one has a name it goes by, under the scratch a session keeps for itself, which is what a file opened by hand is recognised by.
(defvar konix/agent-shell-workspace-default-name ".agent-shell/tmp/konix-workspace.org"
  "Name a workspace goes by.")

bound to the session

rule
the binding lives on the session.
  • why: a writer that has started a workspace must not wander off it.
rule
the writer may start a workspace itself, and moving a bound one is the user’s.
rule
the binding is kept in a store on disk.
  • why: a writer that resumes has to find its binding again.
  • how: the session id is the key it is filed under, as for the note governing a session.
(defvar-local konix/agent-shell-workspace--file nil
  "Document this agent-shell session writes its questions into.")

(defvar konix/agent-shell-workspace-store
  (konix/agent-shell-session-store-create
   :file (expand-file-name "konix/konix-workspaces.el"
                           user-emacs-directory))
  "Store mapping a session id to the document it writes its questions into.")

(defvar konix/agent-shell-workspace--binding
  (konix/agent-shell-session-binding-create
   :store konix/agent-shell-workspace-store
   :variable 'konix/agent-shell-workspace--file
   :on-bind
   (lambda (shell file)
     (if (not file)
         (konix/agent-shell-workspace--unsteer shell)
       (konix/agent-shell-workspace--whitelist-answering-tools shell)
       (konix/agent-shell-workspace--steer shell)
       (konix/agent-shell-workspace--show file shell nil))
     (konix/agent-shell-workspace--tint-session shell)))
  "What a session's workspace is bound through.")
how
reading it, binding it and dropping it are that binding’s three doors.
(defun konix/agent-shell-workspace-file (&optional shell)
  "Return the document SHELL writes its questions into, or nil."
  (konix/agent-shell-session-binding-value
   konix/agent-shell-workspace--binding shell))

(defun konix/agent-shell-workspace--remember (shell file)
  "Bind FILE as SHELL's workspace, buffer-local and on disk."
  (konix/agent-shell-session-binding-bind
   konix/agent-shell-workspace--binding shell file))

(defun konix/agent-shell-workspace--forget (shell)
  "Drop SHELL's workspace, buffer-local and on disk."
  (with-current-buffer shell
    (setq-local konix/agent-shell-workspace--briefed nil))
  (konix/agent-shell-session-binding-unbind
   konix/agent-shell-workspace--binding shell))
how
which shell is calling comes from the tool call itself, and failing that from whichever agent-shell the request was routed through.
(defun konix/agent-shell-workspace--writer ()
  "Return the agent-shell buffer calling this tool, or nil."
  (or (and (buffer-live-p konix/mcp-server--calling-buffer)
           konix/mcp-server--calling-buffer)
      (konix/mcp-server--calling-agent-buffer)))
rule
a workspace tool called from a session with nothing bound is refused, and never falls back on a file of its own choosing.
  • why: such a tool has nothing to act on when no workspace is bound.
how
a tool asks for the calling session’s workspace, and is told plainly when there is none.
(defun konix/agent-shell-workspace--file-or-error ()
  "Return the document the calling session writes its questions into."
  (let ((writer (konix/agent-shell-workspace--writer)))
    (unless (buffer-live-p writer)
      (error "Cannot tell which session is calling, so nothing is written"))
    (or (konix/agent-shell-workspace-file writer)
        (error "No workspace is bound to this session — ask the user to bind one"))))

kept in front of the user

rule
binding opens the file too, read-only and auto-reverting.
rule
and reaching it again is one key from wherever the user talks to the writer.
rule
and it is put in the round the user walks on two counts only: a question is asking them something, or nobody has anything left.
  • why: the user sends a batch of work and reads it when the batch is done, so a question finished, or a fact written, is no reason to disturb them.
  • why: and a question asking them stops the writer, so they have to know.
  • why: and a writer that is done leaves the round itself, so nothing would say the work had stopped.
  • scope: what is finished, and what is written to be read, are theirs all the same — the walk stops on them, the round does not carry them.
  • rule: and it is put there when what asks them changes, not on every write.
    • why: while one question waited, every write of the writer’s sent them back to a workspace they had already read.
    • how: what asked when it was last put there is remembered, and an unchanged answer puts it there no second time.
  • scope: and it leaves the round as soon as neither count holds.

jumping to the shell and back

what
a question turns the user’s attention to something already discussed, so the user jumps to the shell to see what the writer did, and interrupts it to guide it if it needs guiding.
what
then one key takes the user back to the workspace, to answer the rest.
  • rule: and it lands on a question that is theirs, as stepping in from the tracking does.
    • why: whichever way the user arrives, what they arrived for is a question of theirs.
(defun konix/agent-shell-workspace-goto ()
  "Visit the workspace bound to the agent-shell this is called from."
  (declare (modes agent-shell-mode
                  agent-shell-viewport-view-mode
                  agent-shell-viewport-edit-mode))
  (interactive)
  (let* ((shell (konix/agent-shell--current-shell-or-error))
         (file (or (konix/agent-shell-workspace-file shell)
                   (user-error "No workspace is bound to that session"))))
    (unless (file-readable-p file)
      (user-error "Workspace gone from disk: %s" file))
    (pop-to-buffer (konix/agent-shell-workspace--show file shell nil))
    (konix/agent-shell-workspace--land-on-a-users-question)))

(with-eval-after-load 'agent-shell
  (define-key agent-shell-mode-map (kbd "O")
              (lambda ()
                (interactive)
                (konix/agent-shell--permission-key-maybe-insert
                 #'konix/agent-shell-workspace-goto)))
  (define-key agent-shell-viewport-view-mode-map (kbd "O")
              #'konix/agent-shell-workspace-goto))

picking one of the project’s own

what
the workspaces of a project sit in one place, so one command asks which of them is meant and goes there.
  • why: the user comes back to a piece of work with nothing open to jump from, and a path typed out is not how they hold a day-old subject in mind.
  • scope: one of them is not asked about, and none at all says so.
rule
and it starts nothing: the workspace opens, and the writer with it when there is something to say to it.
  • why: the first subject the user raises resumes the session the workspace names, so starting one at the picking is starting it to sit there.
  • therefore: picking one costs nothing and can be done to look, which is what picking is for.
how
the user is left in the workspace, landed there the way arriving at one lands them by any other road.
scope
it binds nothing, opening what is already bound and written down.
(defun konix/agent-shell-workspace--of-this-project ()
  "Return the workspaces of the project this buffer sits in, by their paths."
  (let ((where (file-name-as-directory
                (expand-file-name
                 konix/agent-shell-workspace-directory
                 (konix/agent-shell-workspace--project-of (current-buffer))))))
    (when (file-directory-p where)
      (seq-filter (lambda (file) (string-suffix-p ".org" file))
                  (directory-files where t)))))

(defun konix/agent-shell-workspace-pick ()
  "Go to one of this project's workspaces, starting nothing."
  (interactive)
  (let* ((files (konix/agent-shell-workspace--of-this-project))
         (file (pcase (length files)
                 (0 (user-error "No workspace of this project to pick from"))
                 (1 (car files))
                 (_ (expand-file-name
                     (completing-read "Workspace: "
                                      (mapcar #'file-name-nondirectory files)
                                      nil t)
                     (file-name-directory (car files)))))))
    (pop-to-buffer (find-file-noselect file))
    (konix/agent-shell-workspace--land-on-a-users-question)))

steered back to the workspace

what
the writer starts on prose the user will not read, and the steering stops it there and sends it to the workspace.
what
the question it writes instead comes padded, and the limits refuse that too.
therefore
what reaches the user is a question carrying information and no noise, which the user answers, and round it goes.

what the steering does

rule
a writer that narrates to the user while a workspace is bound is steered back to it, whatever the workspace holds.
  • why: a narrating writer piles up prose the user never reads, then takes that silence for agreement.
  • why: the chat is the wrong place whatever the workspace holds, so one condition covers every case and none slips through.
how
a named evaluator, referenced as a steering key, firing on the events that carry the writer’s prose — never on its tool calls.
  • why: a tool call is the writer working, and needs no guarding.
  • scope: a message of nothing but blanks and zero-width fillers is not prose.
    • why: a writer polling a build was cut short between its calls, its whole message being one zero-width space.
    • therefore: what counts as narration is what the user could read, not whatever the agent put on the wire.
scope
and the user can drop the whole arrangement, workspace and steering together, wanting the plain conversation back.

what is left to the writer

rule
work of the writer’s own is a question still the writer’s, and nothing else.
  • why: a question still the writer’s is work it has not taken up.
  • scope: a fact no question reaches is not the writer’s to take back, K being how the user clears those out.
  • therefore: whether anything is the writer’s own is one question, asked wherever the arrangement has to know whether it is done.
(defun konix/agent-shell-workspace--anything-left-p ()
  "Non-nil when this buffer still holds work of the writer's own."
  (save-excursion
    (goto-char (point-min))
    (konix/agent-shell-workspace--search-state
     konix/agent-shell-workspace-writers-regexp)))
rule
and what is left is named by the listing itself, whole, rather than by reciting the conditions.
  • why: the writer keeps trying to move on while there are questions to read, so it must be told which ones.
  • rule: and it carries what is written under each, the user’s answers included.
    • why: named by their headings alone, the writer took one up and acted without ever reading what the user had said on it.
  • how: it is written afresh whenever the workspace changes, since what it names is whatever stands there at the time.
    • what: every rewrite goes through publishing the file, and every word the user says goes through telling the writer, so those two are where it is written.
(defun konix/agent-shell-workspace--writers-questions ()
  "Return (ID . HEADING) for each question of this buffer still the writer's."
  (let (found)
    (save-excursion
      (goto-char (point-min))
      (while (konix/agent-shell-workspace--search-state
              (concat konix/agent-shell-workspace-writers-regexp "\\(.*\\)$"))
        (let* ((heading (match-string 2))
               (limit (konix/agent-shell-workspace--question-end))
               (id (save-excursion
                     (when (re-search-forward "^ *:ID: *\\(.+\\)$" limit t)
                       (string-trim (match-string 1))))))
          (push (cons (or id "-") heading) found)
          (goto-char limit))))
    (nreverse found)))
the turn cut short
rule
with nothing left of its own the writer’s turn is cut short for it, nothing being said to it.
  • why: it must not waste tokens on a turn with no work in it.
  • therefore: nothing tells it what to do with nothing left, there being no turn left to tell.
  • how: the cut rides the rewrite that emptied the workspace, so it lands the moment the last question stops being the writer’s.
    • rule: and a change of the user’s own rides it too, every one of their keys going through the one write.
      • why: they put the last question off, or settled it, or dropped it, and the writer went on working with nothing there to work on.
      • therefore: it is not the writer emptying it that cuts, but the workspace being empty after anything at all.
    • rule: and a writer with nothing left is cut rather than told what changed.
      • why: being told to read a workspace back and finding nothing in it is a turn spent to be cut at the end of it.
(defun konix/agent-shell-workspace--writer-done-p (file)
  "Non-nil when FILE leaves the writer nothing of its own to work on."
  (and (file-readable-p file)
       (konix/agent-shell-workspace--read-file file
         (not (konix/agent-shell-workspace--anything-left-p)))))

(defvar-local konix/agent-shell-workspace--cut nil
  "Non-nil while this writer's turn was cut short for want of work.")

(defun konix/agent-shell-workspace--cut-short (writer)
  "Cut WRITER's turn short when its workspace leaves it nothing to work on."
  (when-let ((file (konix/agent-shell-workspace-file writer)))
    (when (konix/agent-shell-workspace--writer-done-p file)
      (with-current-buffer writer
        (when (or (shell-maker-busy)
                  (konix/agent-shell--pending-permission-ids))
          (setq-local konix/agent-shell-workspace--cut t)
          (let ((agent-shell-confirm-interrupt nil))
            (ignore-errors (agent-shell-interrupt)))
          (ignore-errors
            (konix/agent-shell--cancel-pending-permissions))
          (konix/agent-shell-workspace--leave-the-round writer))))))
out of the round the user walks
rule
a writer with nothing of its own stays out of the round the user walks, and leaves it where it already stood there.
  • why: a session that stops is a session waiting on nobody, and the round is for what waits on the user.
  • how: the tracking asks whoever knows whether a ready session is for the user, and this arrangement answers for the one it bound.
    • therefore: nothing there knows what a workspace is, and the answer is given where the reason lives.
  • how: and the answer is given again at the end of every turn, since a session taken into the round before it was done would otherwise sit there.
  • scope: only that one leaves; every other reason the round holds a session is none of this arrangement’s business.
(defun konix/agent-shell-workspace--leave-the-round (writer)
  "Take WRITER, and whatever shows it, out of the round the user walks."
  (tracking-remove-buffer writer)
  (when-let ((shown (agent-shell-viewport--buffer
                     :shell-buffer writer :existing-only t)))
    (tracking-remove-buffer shown)))

(defun konix/agent-shell-workspace--nothing-here-for-the-user-p ()
  "Non-nil when this session's own workspace leaves it nothing to do."
  (when-let ((file (konix/agent-shell-workspace-file (current-buffer))))
    (konix/agent-shell-workspace--writer-done-p file)))

(add-hook 'konix/agent-shell-track-ready-skip-functions
          #'konix/agent-shell-workspace--nothing-here-for-the-user-p)
apart in the tree the user watches
rule
the tree the user watches shows such a writer apart from one that waits with work left.
  • why: both read as waiting, so the user opens the one with nothing in it and finds nothing to do.
  • how: a line wears the colour of its own badge, so a colour of its own is a face of its own, and not a word more.
    • why: the tree is a window the user keeps narrow, and a badge saying « nothing left » would cost every line of it the width.
    • therefore: the badge still reads [idle], the tree’s own words are untouched, and what changes is what the colour says.
  • how: and the tree asks the way the tracking asks: where it has settled on the badge for a session waiting on nobody, it lets whoever knows give the face, and wears whatever comes back.
    • therefore: the tree names no workspace anywhere, and this arrangement names nothing of the tree but the one place it answers at.
  • scope: only where the session waits on nobody — an error, or a permission pending, is what the user wants to see first, whatever its workspace says.
  • scope: and the status itself stays what it was, so everything else the tree reads off it — which of them are working, what the whole window says — is none of this arrangement’s business.
  • how: the face is a named one of this arrangement’s own, shadowed, so a session with nothing in it recedes rather than calling for the eye.
    • why: named faces rather than colours, the way the keywords are faced, so the theme decides how it looks.
(defface konix/agent-shell-workspace-nothing-left-face
  '((t :inherit shadow :weight bold))
  "Face the badge of a session whose workspace leaves it nothing wears."
  :group 'agent-shell)

(defun konix/agent-shell-workspace--nothing-left-face ()
  "Return the face for this session's badge, its workspace leaving it nothing."
  (when (konix/agent-shell-workspace--nothing-here-for-the-user-p)
    'konix/agent-shell-workspace-nothing-left-face))

(add-hook 'konix/mcp-server-status-face-functions
          #'konix/agent-shell-workspace--nothing-left-face)
a permission left pending
rule
a writer waiting on a permission is cut too, the permission cancelled with it.
  • why: it sat there asking to be let on with work that is no longer its own.
  • therefore: nothing of the writer’s is left waiting for the user to look at it.
what the shell says of a cut turn
rule
where the shell would say the turn was cancelled it says nothing more is the writer’s to do.
  • why: a cancel reads as one done by hand, hiding that the work simply ran out.
  • how: the cut leaves a mark on the session it cut, and the block the shell writes as the turn ends is rewritten from it.
    • scope: so a cancel by hand still reads as one, as does the one the steering takes when the writer narrates.
    • rule: and the mark goes at the end of every turn, whichever way it ended.
      • why: a mark left standing would put those words on the next cancel, which would be a lie about a different turn.
    • rule: and the workspace is read again as the words are said, not trusted from when the cut was taken.
      • why: work arrives between the two — the user writes a subject, or the writer takes one up — and then the words would be false of the very turn they label.
(defconst konix/agent-shell-workspace-cut-said "Nothing more to do for the writer"
  "What the shell says where it would have said the turn was cancelled.")

(defun konix/agent-shell-workspace--say-cut (event)
  "Say on EVENT what a turn cut short for want of work was, in the shell's own block."
  (when (and konix/agent-shell-workspace--cut
             (equal (map-elt (map-elt event :data) :stop-reason) "cancelled")
             (when-let ((file (konix/agent-shell-workspace-file (current-buffer))))
               (konix/agent-shell-workspace--writer-done-p file)))
    (agent-shell--update-fragment
     :state (agent-shell--state)
     :block-id (format "%s-stop-reason"
                       (map-elt (agent-shell--state) :request-count))
     :body konix/agent-shell-workspace-cut-said)))
told to carry on
rule
a turn that ends of its own accord with work still the writer’s has the writer told to carry on.
  • why: the writer can stop while something is still its own, and then nothing moves until the user notices.
  • scope: a turn the user cut short stays cut short, cutting one being how they stop a writer rather than how they pause it.
  • rule: and it is told once for each state the workspace is in.
    • why: a writer that stops again having done nothing would otherwise be told for ever.
    • therefore: one that ignores it goes idle instead, which is where the user finds a session that has stopped working.
rule
and one command does by hand what the end of a turn does by itself.
  • why: whatever the arrangement settles on its own, the user must be able to ask for on the spot.
  • what: it steers the session again, cuts a writer with nothing left, and tells one with work left to carry on.
  • scope: asked for by hand it tells the writer whatever the workspace says now, the once-per-state rule being for what fires by itself.
  • how: it is bound to no key, being reached by its name.
(defvar-local konix/agent-shell-workspace--pushed nil
  "What was left to this writer when it was last told to carry on.")

(defun konix/agent-shell-workspace--push-on (writer)
  "Tell WRITER to carry on, its turn having ended with work of its own left."
  (when-let* ((file (konix/agent-shell-workspace-file writer))
              (left (when (file-readable-p file)
                      (konix/agent-shell-workspace--listing file t))))
    (with-current-buffer writer
      (unless (equal left konix/agent-shell-workspace--pushed)
        (setq-local konix/agent-shell-workspace--pushed left)
        (ignore-errors (konix/agent-shell-workspace--submit writer))))))

(defun konix/agent-shell-workspace--turn-ended (event)
  "Say on EVENT what a cut turn was, or push a writer that stopped with work left."
  (konix/agent-shell-workspace--say-cut event)
  (setq-local konix/agent-shell-workspace--cut nil)
  (when (konix/agent-shell-workspace--nothing-here-for-the-user-p)
    (konix/agent-shell-workspace--leave-the-round (current-buffer)))
  (when (equal (map-elt (map-elt event :data) :stop-reason) "end_turn")
    (konix/agent-shell-workspace--push-on (current-buffer))))

(defvar-local konix/agent-shell-workspace--watching nil
  "Non-nil once this shell is listening for the end of its turns.")

(defun konix/agent-shell-workspace--watch-turns ()
  "Have this shell answer the end of its own turns, once."
  (unless konix/agent-shell-workspace--watching
    (setq-local konix/agent-shell-workspace--watching t)
    (let ((buffer (current-buffer)))
      (agent-shell-subscribe-to
       :shell-buffer buffer :event 'turn-complete
       :on-event (lambda (event)
                   (when (buffer-live-p buffer)
                     (with-current-buffer buffer
                       (konix/agent-shell-workspace--turn-ended event))))))))

(add-hook 'agent-shell-mode-hook #'konix/agent-shell-workspace--watch-turns)

(defun konix/agent-shell-workspace-see-to-the-writer ()
  "Do to this session's writer what the end of a turn does: steer, cut, or push it."
  (declare (modes agent-shell-mode
                  agent-shell-viewport-view-mode
                  agent-shell-viewport-edit-mode
                  konix/agent-shell-workspace-mode))
  (interactive)
  (let* ((writer (konix/agent-shell-workspace--shell-here))
         (file (or (konix/agent-shell-workspace-file writer)
                   (user-error "No workspace is bound to that session"))))
    (konix/agent-shell-workspace--steer writer)
    (if (konix/agent-shell-workspace--writer-done-p file)
        (progn
          (konix/agent-shell-workspace--cut-short writer)
          (message "Nothing is %s's any more" (buffer-name writer)))
      (with-current-buffer writer
        (setq-local konix/agent-shell-workspace--pushed nil))
      (konix/agent-shell-workspace--push-on writer)
      (message "%s was told to carry on" (buffer-name writer)))))
what a question reaches
what
a question reaches a fact by an id: link, and a fact reaches another the same way, so what a question reaches is a path and not a hop.
  • scope: an id merely named in a bullet’s words reaches nothing, being no link.
(defun konix/agent-shell-workspace--ids-linked-from (start limit)
  "Return the ids linked from between START and LIMIT."
  (let (ids)
    (save-excursion
      (goto-char start)
      (while (re-search-forward "\\[\\[id:\\([^]]+\\)\\]" limit t)
        (push (string-trim (match-string 1)) ids)))
    ids))
how
one pass over the headings puts every question’s links on a list to follow and every fact aside by its id.
  • scope: a DONE question is still a question, so a fact it reaches is reached, and the refusal bites only once the user has cleared it away.
(defun konix/agent-shell-workspace--gather-facts (facts queue)
  "Fill FACTS with this buffer's facts by id, and return QUEUE with what a question links."
  (save-excursion
    (goto-char (point-min))
    (while (re-search-forward "^\\* \\(.*\\)$" nil t)
      (let* ((line (match-string 0))
             (start (line-beginning-position))
             (limit (konix/agent-shell-workspace--question-end))
             (id (save-excursion
                   (when (re-search-forward "^ *:ID: *\\(.+\\)$" limit t)
                     (string-trim (match-string 1))))))
        (if (konix/agent-shell-workspace--state-in line)
            (setq queue
                  (append (konix/agent-shell-workspace--ids-linked-from start limit)
                          queue))
          (when id (puthash id (cons start limit) facts)))
        (goto-char limit))))
  queue)
how
then the list is drained, each id marked as reached, and a fact’s own links added to it, until nothing is left to follow.
(defun konix/agent-shell-workspace--follow-links (facts queue)
  "Return the ids reached from QUEUE, following the links FACTS carry."
  (let ((reached (make-hash-table :test 'equal)))
    (while queue
      (let ((id (pop queue)))
        (unless (gethash id reached)
          (puthash id t reached)
          (when-let ((span (gethash id facts)))
            (setq queue
                  (append (konix/agent-shell-workspace--ids-linked-from
                           (car span) (cdr span))
                          queue))))))
    reached))
what
what the marking never reached is a fact no question stands behind.
  • scope: facts pointing only at each other are reached by nothing, so they go too.
rule
and a fact the user has pinned is reached whatever the questions say, so no sweep takes it.
  • why: most of what the writer reports is cleared unread, and the few worth keeping had nothing holding them once their question was settled.
  • how: a tag org itself carries, so which are pinned is read off the headings without opening anything.
  • what: p pins the fact point stands on, and takes the pin back off it.
    • scope: a question is no fact, and the key says so rather than pinning it.
(defconst konix/agent-shell-workspace-pinned-tag "pinned"
  "Tag a fact the user wants kept wears, which no sweep takes.")

(defun konix/agent-shell-workspace--pinned-p ()
  "Non-nil when the heading point stands on is one the user has pinned."
  (member konix/agent-shell-workspace-pinned-tag (org-get-tags nil t)))

(defun konix/agent-shell-workspace--unlinked-facts ()
  "Return the ids of this buffer's facts no path from a question reaches."
  (let* ((facts (make-hash-table :test 'equal))
         (queue (konix/agent-shell-workspace--gather-facts facts nil))
         (reached (konix/agent-shell-workspace--follow-links facts queue))
         unlinked)
    (maphash (lambda (id span)
               (unless (or (gethash id reached)
                           (save-excursion
                             (goto-char (car span))
                             (konix/agent-shell-workspace--pinned-p)))
                 (push id unlinked)))
             facts)
    unlinked))

held to taking one up

rule
a writer holding no question is held back from every tool but the ones that let it take one up.
  • why: the user wants to know which one it is on so as to anticipate the question coming.
  • scope: it reaches every call the writer makes, since the steering reads the calls going by rather than the leave they ask for.
    • therefore: a tool the session’s own settings wave through, asking Emacs nothing, is held back all the same.
rule
what it is told carries the list of the permitted, not of the refused.
  • what: the permitted are reading the headings back, moving one across the states, writing a question of its own, and dropping what is settled.
    • scope: writing one carries what the user has just said into the workspace, not a subject the writer found for itself with nothing left to do.
  • rule: and whatever the session fetches a tool’s own schema with is permitted too.
    • why: a writer that cannot reach for the tool that takes a question up cannot take one up, and the refusal becomes a wall rather than a door.
(defconst konix/agent-shell-workspace-taken-up-key "@workspace-nothing-taken-up"
  "Steering key holding back a writer that holds no question.")

(defun konix/agent-shell-workspace--only-thinking-p (subject)
  "Non-nil when SUBJECT is the session thinking rather than working."
  (equal (map-elt subject :kind) "think"))

(defconst konix/agent-shell-workspace-schema-tool "ToolSearch"
  "Tool a session fetches another tool's own schema with.")

(defun konix/agent-shell-workspace--permitted-tools ()
  "Return the whole titles a writer holding no question may still call."
  (let ((server (format "mcp__%s__" konix/agent-shell-workspace-server-name)))
    (list (concat server konix/mcp-server-read-only-prefix
                  "list_workspace_questions")
          (concat server "set_workspace_state")
          (concat server "set_workspace_question")
          (concat server "delete_workspace_question")
          konix/agent-shell-workspace-schema-tool)))
how
what it is told names every way on, and the two ways in: the writer put its question down, or the user took it away underneath it.
rule
and it names what is left to the writer: every question of its own, with what is written under each.
  • why: the nudge should provide the list of questions that could be taken up.
  • therefore: the writer acts from the refusal itself, without a call to read the workspace back first.
  • rule: and it is the listing itself, the same one the nudge and the tool give.
  • rule: and it says that a workspace is already bound.
    • why: a freshly bound writer reaches for binding, is refused, and reaches for it again until the cap, the refusal never telling it that is the mistake.
    • therefore: the refusal names the one call the writer is likeliest to make wrongly, not only the ones it should make instead.
  • scope: with none to take it says the turn is being cut short there, and nothing else.
    • why: the writer would otherwise say things the user does not read, and go on as if they had agreed to them.
    • therefore: nothing is asked of a writer whose turn is ending, where every other refusal of this arrangement names a tool to call.
how
each is matched on its whole name, spelled out from the server’s own, so no tool merely carrying one of those names is let through.
  • what: the matching itself belongs to the steering engine, which knows nothing of workspaces, so this one only names the six and asks.
how
it catches a call and nothing else, prose being the other key’s to catch.
  • scope: and a call that only thinks goes through, a session that thinks having worked on nothing.
    • why: the shell folds the conversation up through a call of that kind, and held back it loses the very turn it was making room to go on with.
    • scope: the key keeping the file to itself lets one through as well, the conversation it folds up naming the workspace all through.

held off reading the file itself

rule
a call that reads the bound workspace file is held back, whatever tool it comes through.
  • why: writers tend to do that and it’s annoying, the tools being supposed to provide everything they need.
  • what: the listing gives back every heading, what is written under it and whose turn it is, in the form the tools mean.
  • scope: the workspace’s own tools are let through, naming the file being how they are asked at all.
  • how: it matches the path anywhere in what the call asks for, so a shell command naming it is caught as readily as a read.
(defconst konix/agent-shell-workspace-read-directly-key "@workspace-read-directly"
  "Steering key holding back a call that reads the workspace file itself.")

(defconst konix/agent-shell-workspace-read-directly-guidance
  (concat "Do not read the workspace file yourself. Every heading in it, what is"
          " written under each and whose turn it is, comes back from"
          " list_workspace_questions in the form these tools mean, so reading the"
          " file raw spends the turn on markup you would have to unpick. Read it"
          " back with the tool instead.")
  "What a writer is told when it goes for the workspace file itself.")

(defun konix/agent-shell-workspace--own-tool-p (subject)
  "Non-nil when SUBJECT is a call to one of the workspace's own tools."
  (string-prefix-p (format "mcp__%s__" konix/agent-shell-workspace-server-name)
                   (or (map-elt subject :title) "")))

(konix/agent-shell-define-tool-evaluator "workspace-read-directly" (subject)
  "Hold back a call reading the bound workspace file rather than asking for it."
  (and (map-elt subject :title)
       (not (konix/agent-shell-workspace--only-thinking-p subject))
       (not (konix/agent-shell-workspace--own-tool-p subject))
       (when-let ((file (konix/agent-shell-workspace-file (current-buffer))))
         (konix/agent-shell-tool-mentions-p subject file))
       t))
(defconst konix/agent-shell-workspace-taken-up-guidance
  (concat "Take a question up before you work on anything, and then act. If this"
          " refusal arrived in the middle of a turn, you hold none: either you put"
          " yours down, or the user took it away underneath you. A workspace is"
          " already bound, so set_workspace is not the way on and calling it again"
          " only costs you the turn. The one you take up is the only thing you work"
          " on: whatever else you notice is a question to write, not work to do. And a"
          " place is a link you pass file and line for, never directions in prose.")
  "What a writer is told when it works while holding no question.")

(defconst konix/agent-shell-workspace-holding-said
  (concat "You have committed to %s. It is now the only thing you work on:"
          " not the next thing you notice, not the thing that looks quicker, not"
          " what you were doing before. Anything else you see is a question to"
          " write and leave with the user, never work to do. Every doubt you have"
          " is put to them as a question about this one, asked so that this one"
          " gets done. Where what it asks is itself unclear, refine it and say what"
          " you need — do not hesitate and do not guess: a guess costs you the work"
          " it sends you off to do, and them the reading of it, where asking costs"
          " one sentence. Nothing lets you off it but putting it down or handing it"
          " back, and either of those is said with set_workspace_state.")
  "What a writer is told as it takes one up, that question's words filled in.")

(defconst konix/agent-shell-workspace-no-goal-said
  (concat "This workspace names no goal, so ask the user what the work is before"
          " you go far into that one.")
  "What a writer is told where nothing says what the work is.")

(defun konix/agent-shell-workspace--taken-up-said (file)
  "Return what a writer holding no question is told, naming what in FILE is left to it."
  (let ((left (when (and file (file-readable-p file))
                (konix/agent-shell-workspace--listing file t))))
    (concat konix/agent-shell-workspace-taken-up-guidance
            (if left
                (concat "\n\nWhat is left to you:\n" left)
              (concat "\n\nNothing there is yours to take, so there is nothing to do"
                      " and this turn is being cut short for you. Do not invent a"
                      " question to have something to do — only what the user has just"
                      " told you belongs there.")))))

(defun konix/agent-shell-workspace--nothing-taken-up-p (file)
  "Non-nil when FILE holds no question the writer has taken up."
  (and (file-readable-p file)
       (konix/agent-shell-workspace--read-file file
         (not (konix/agent-shell-workspace--working-p)))))

(konix/agent-shell-define-tool-evaluator "workspace-nothing-taken-up" (subject)
  "Hold back a writer calling a tool while it holds no question."
  (and (map-elt subject :title)
       (not (konix/agent-shell-workspace--only-thinking-p subject))
       (not (konix/agent-shell-tool-named-p
             subject (konix/agent-shell-workspace--permitted-tools)))
       (when-let ((file (konix/agent-shell-workspace-file (current-buffer))))
         (konix/agent-shell-workspace--nothing-taken-up-p file))
       t))

what the steering is made of

what
a key names each piece of the arrangement, so binding can put those on and unbinding can take those off without touching whatever else steers the session.
  • what: there are three — the writer wrote prose, the writer works holding no question, the writer reads the file itself — and they go on and come off together.
(defconst konix/agent-shell-workspace-bound-key "@workspace-bound"
  "Steering key holding back prose the writer addressed to the user.")

(defconst konix/agent-shell-workspace-steering-keys
  (list konix/agent-shell-workspace-bound-key
        konix/agent-shell-workspace-taken-up-key
        konix/agent-shell-workspace-read-directly-key)
  "Steering keys the workspace installs on a bound session.")
what
it matches on two things at once: that the writer wrote the user prose, and that a workspace is bound to say the prose belonged elsewhere.
(defconst konix/agent-shell-workspace-nothing-said-regexp
  "\\`[[:space:]\u00a0\u200b\u200c\u200d\ufeff]*\\'"
  "What a message saying nothing looks like, its zero-width fillers and all.")

(defun konix/agent-shell-workspace--talking-to-user-p (subject)
  "Non-nil when SUBJECT carries prose the writer addressed to the user."
  (not (string-match-p konix/agent-shell-workspace-nothing-said-regexp
                       (or (cdr (assq :last-message subject)) ""))))

(konix/agent-shell-define-tool-evaluator "workspace-bound" (subject)
  "Match the writer's prose while a workspace is bound, whatever it holds."
  (and (konix/agent-shell-workspace--talking-to-user-p subject)
       (konix/agent-shell-workspace-file (current-buffer))
       t))
what
what it is told when it matches is the whole arrangement in one paragraph.
  • rule: and it names the fact as well as the question, so prose that asks nothing has somewhere to go.
    • why: when preventing the narration, we can tell that facts can be added.
    • therefore: the writer stops choosing between padding a question and saying nothing at all.
  • rule: and with nothing left of the writer’s this key says nothing, so its prose is not steered at all.
    • why: steering hands the words back and starts the turn again, undoing the cut that had just ended it.
    • scope: a call of the writer’s is held back as ever, a turn already over making none.
(defconst konix/agent-shell-workspace-steering-guidance
  (list
   (cons konix/agent-shell-workspace-bound-key
         (concat "A workspace is bound to this session, so use it rather than this"
                 " chat. Read it back, act on the questions that are yours, and put"
                 " what you were about to say there: as a question if it asks"
                 " something, as a fact if it only tells. Then work on something else."
                 " With nothing left of yours the turn is cut short for you, so there"
                 " is nothing to say here at all."))
   (cons konix/agent-shell-workspace-taken-up-key
         konix/agent-shell-workspace-taken-up-guidance)
   (cons konix/agent-shell-workspace-read-directly-key
         konix/agent-shell-workspace-read-directly-guidance))
  "What a writer is steered with, per key.")

(defun konix/agent-shell-workspace--steering-guidance (shell)
  "Return what SHELL is steered with, the workspace's goal ahead of each."
  (let* ((file (konix/agent-shell-workspace-file shell))
         (goal (if file (konix/agent-shell-workspace--goal-line file) ""))
         (said (if (and file (konix/agent-shell-workspace--writer-done-p file))
                   (assoc-delete-all
                    konix/agent-shell-workspace-bound-key
                    (copy-alist konix/agent-shell-workspace-steering-guidance))
                 konix/agent-shell-workspace-steering-guidance)))
    (append
     (mapcar
      (lambda (entry)
        (cons (car entry)
              (concat goal
                      (if (equal (car entry)
                                 konix/agent-shell-workspace-taken-up-key)
                          (konix/agent-shell-workspace--taken-up-said file)
                        (cdr entry)))))
      said)
     (konix/agent-shell-workspace--its-own-steering file))))

(defvar-local konix/agent-shell-workspace--its-own-keys nil
  "Steering keys this session carries because its workspace named them.")

(defun konix/agent-shell-workspace--install-steering (shell)
  "Put the workspace's own steering rules on SHELL, replacing any it had."
  (let ((guidance (konix/agent-shell-workspace--steering-guidance shell)))
    (with-current-buffer shell
      (let ((rules (copy-alist konix/agent-shell-steering-rules)))
        (dolist (key (append konix/agent-shell-workspace-steering-keys
                             konix/agent-shell-workspace--its-own-keys
                             (mapcar #'car guidance)))
          (setq rules (assoc-delete-all key rules)))
        (setq-local konix/agent-shell-workspace--its-own-keys
                    (seq-remove
                     (lambda (key)
                       (member key konix/agent-shell-workspace-steering-keys))
                     (mapcar #'car guidance)))
        (setq-local konix/agent-shell-steering-rules
                    (append guidance rules))))))

(defun konix/agent-shell-workspace--steer (shell)
  "Make SHELL steer itself back to the workspace rather than talk to the user."
  (konix/agent-shell-workspace--install-steering shell)
  (konix/agent-shell-workspace--install-its-own shell)
  (with-current-buffer shell
    (konix/agent-shell-workspace--watch-turns)
    (add-hook 'kill-buffer-hook
              #'konix/agent-shell-workspace--kill-with-writer nil t)))

rules the workspace names itself

what
a workspace names its own steering, its own whitelist and its own blacklist in its front matter, and they go on the writer with the arrangement’s own.
  • why: what a writer must be held to differs from one piece of work to the next.
  • how: one #+STEERING:, #+WHITELIST: or #+BLACKLIST: line each, naming what it matches and then what it says, as many as wanted.
  • rule: they are read afresh at binding and at every rewrite, so editing the file is how they change.
  • what: S, B and W open a panel over these very lines, built the way the viewport’s own panels are: a adds, e or RET changes, d drops.
    • why: the user knows those panels, and a workspace is no place to learn a second way of saying the same thing.
    • rule: and it offers the same list to match on that the viewport’s own panels do, the tools and the evaluators there are.
    • rule: and the writer is steered again as each edit lands, so a rule bites without waiting for a rewrite.
    • scope: the three are capitals, so w sets the goal and C-w reads it again.
  • rule: and the arrangement’s own three come first, a workspace naming a key of theirs saying nothing.
    • why: the user must not lock a writer out of the very keys that hold it to the workspace.
(defun konix/agent-shell-workspace--rule-write (key what now)
  "Make this workspace's KEY line for WHAT read NOW, or drop it when NOW is nil."
  (konix/agent-shell-workspace--write
    (save-excursion
      (goto-char (point-min))
      (if (and what
               (re-search-forward
                (format "^#\\+%s: *%s *\\(?:::.*\\)?$" key (regexp-quote what))
                nil t))
          (progn
            (delete-region (line-beginning-position)
                           (min (point-max) (1+ (line-end-position))))
            (when now (insert (format "#+%s: %s\n" key now))))
        (when now
          (konix/agent-shell-workspace--goto-front-matter)
          (insert (format "#+%s: %s\n" key now))))))
  (konix/agent-shell-workspace--steer
   (konix/agent-shell-workspace--target-writer)))

(defun konix/agent-shell-workspace--rule-do (what now)
  "From a panel, make the line for WHAT read NOW in the workspace behind it."
  (let ((key (konix/agent-shell-panel-current-data)))
    (with-current-buffer (konix/agent-shell-panel--origin-buffer)
      (konix/agent-shell-workspace--rule-write key what now)))
  (konix/agent-shell-panel--refresh))

(defun konix/agent-shell-workspace--read-when (&optional initial)
  "Read what a rule matches on, offering the writer's own tools and evaluators."
  (completing-read
   "When (regexp, @evaluator, or (lambda ...)): "
   (ignore-errors
     (with-current-buffer
         (with-current-buffer (konix/agent-shell-panel--origin-buffer)
           (konix/agent-shell-workspace--target-writer))
       (konix/agent-shell--tool-candidates)))
   nil nil initial 'regexp-history))

(defun konix/agent-shell-workspace-rule-add ()
  "Add a rule to the workspace this panel is about."
  (interactive)
  (let ((what (konix/agent-shell-workspace--read-when))
        (said (read-string "What it is told: ")))
    (konix/agent-shell-workspace--rule-do nil (format "%s :: %s" what said))))

(defun konix/agent-shell-workspace-rule-edit ()
  "Change the rule point stands on."
  (interactive)
  (let* ((what (tabulated-list-get-id))
         (key (konix/agent-shell-panel-current-data))
         (said (cdr (assoc what
                           (with-current-buffer
                               (konix/agent-shell-panel--origin-buffer)
                             (konix/agent-shell-workspace--declared
                              (buffer-file-name) key))))))
    (konix/agent-shell-workspace--rule-do
     what (format "%s :: %s"
                  (konix/agent-shell-workspace--read-when what)
                  (read-string "What it is told: " said)))))

(defun konix/agent-shell-workspace-rule-delete ()
  "Drop the rule point stands on."
  (interactive)
  (konix/agent-shell-workspace--rule-do (tabulated-list-get-id) nil))

(defun konix/agent-shell-workspace--rule-panel (key)
  "Return the panel that edits this workspace's own KEY lines."
  (let ((file (buffer-file-name))
        (named (capitalize (downcase key))))
    (konix/agent-shell-panel-create
     :buffer-name (format "*Workspace %s*" named)
     :mode-name (format "Workspace-%s" named)
     :help (format "%s of this workspace: a add, e/RET edit, d delete, g refresh, q quit"
                   named)
     :name-header "When"
     :name-width 40
     :data key
     :rows (lambda ()
             (mapcar #'car (konix/agent-shell-workspace--declared file key)))
     :label (lambda (id) (format "%s" id))
     :value-columns
     (list (list "What it is told" 60
                 (lambda (id)
                   (cdr (assoc id (konix/agent-shell-workspace--declared
                                   file key))))))
     :extra-keys
     '(("a" . konix/agent-shell-workspace-rule-add)
       ("e" . konix/agent-shell-workspace-rule-edit)
       ("RET" . konix/agent-shell-workspace-rule-edit)
       ("d" . konix/agent-shell-workspace-rule-delete)))))

(defun konix/agent-shell-workspace-steering-menu ()
  "Edit the steering rules this workspace names itself."
  (interactive)
  (konix/agent-shell-panel-open
   (konix/agent-shell-workspace--rule-panel "STEERING")))

(defun konix/agent-shell-workspace-blacklist-menu ()
  "Edit the blacklist rules this workspace names itself."
  (interactive)
  (konix/agent-shell-panel-open
   (konix/agent-shell-workspace--rule-panel "BLACKLIST")))

(defun konix/agent-shell-workspace-whitelist-menu ()
  "Edit the whitelist rules this workspace names itself."
  (interactive)
  (konix/agent-shell-panel-open
   (konix/agent-shell-workspace--rule-panel "WHITELIST")))

(defun konix/agent-shell-workspace--declared (file key)
  "Return the (WHAT . SAID) each `#+KEY:' line of FILE declares."
  (when (and file (file-readable-p file))
    (konix/agent-shell-workspace--read-file file
      (mapcar (lambda (line)
                (if-let ((split (string-search "::" line)))
                    (cons (string-trim (substring line 0 split))
                          (string-trim (substring line (+ split 2))))
                  (cons line "")))
              (seq-remove #'string-empty-p
                          (cdr (car (org-collect-keywords (list key)))))))))

(defun konix/agent-shell-workspace--its-own-steering (file)
  "Return the steering rules FILE names itself, the arrangement's own left alone."
  (seq-remove (lambda (one)
                (member (car one) konix/agent-shell-workspace-steering-keys))
              (konix/agent-shell-workspace--declared file "STEERING")))

(defun konix/agent-shell-workspace--install-its-own (shell)
  "Put the whitelist and blacklist SHELL's workspace names itself on it."
  (when-let ((file (konix/agent-shell-workspace-file shell)))
    (with-current-buffer shell
      (dolist (one (konix/agent-shell-workspace--declared file "WHITELIST"))
        (ignore-errors
          (konix/agent-shell-whitelist-tool (car one) (cdr one) 'session)))
      (dolist (one (konix/agent-shell-workspace--declared file "BLACKLIST"))
        (ignore-errors
          (konix/agent-shell-policy--remove-session
           konix/agent-shell--blacklist (car one)))
        (ignore-errors
          (konix/agent-shell-blacklist-tool (car one) (cdr one) 'session))))))
how
unbinding reaches the session from either side, since the user may call it from the shell or from the workspace itself.
(defun konix/agent-shell-workspace--shell-here ()
  "Return the session this buffer is about: the shell, or the workspace's writer."
  (if (bound-and-true-p konix/agent-shell-workspace-mode)
      (konix/agent-shell-workspace--target-writer)
    (konix/agent-shell--current-shell-or-error)))

(defun konix/agent-shell-workspace--unsteer (shell)
  "Take the workspace's steering back off SHELL."
  (with-current-buffer shell
    (let ((rules (copy-alist konix/agent-shell-steering-rules)))
      (dolist (key (append konix/agent-shell-workspace-steering-keys
                           konix/agent-shell-workspace--its-own-keys))
        (setq rules (assoc-delete-all key rules)))
      (setq-local konix/agent-shell-workspace--its-own-keys nil)
      (setq-local konix/agent-shell-steering-rules rules))))

(defun konix/agent-shell-workspace-unbind ()
  "Drop this shell's workspace and its steering, back to plain conversation."
  (declare (modes agent-shell-mode
                  agent-shell-viewport-view-mode
                  agent-shell-viewport-edit-mode
                  konix/agent-shell-workspace-mode))
  (interactive)
  (let ((shell (konix/agent-shell-workspace--shell-here)))
    (konix/agent-shell-workspace--forget shell)
    (konix/agent-shell-workspace--submit
     shell
     (concat "The workspace is unbound. Talk in this chat again, as you"
             " would without one."))
    (message "Workspace unbound")))
rule
and where unbinding leaves everything standing, one command takes the whole thing away: the writer, the workspace and the file it stood in.
  • why: a piece of work that is over leaves a session, a buffer and a file behind, and putting each away by hand is three gestures and a path to remember.
  • rule: and it asks first, naming the file, being the one gesture here that cannot be taken back.
  • how: it reaches the workspace from either side, as unbinding does: from the workspace by the file it stands in, from the shell by what is bound to it.
  • how: the binding is dropped before the writer goes, so nothing of it is left filed under a session that no longer exists.
    • how: and the writer is killed on the next turn of the timer, the command being run from inside it as often as not.
  • how: and the file goes the way the user’s own deleting goes, into the trash where they keep one.
  • scope: a writer already gone is no reason to stop: what is left of the workspace goes all the same.
(defun konix/agent-shell-workspace-cleanup ()
  "Take this workspace away for good: its writer, its buffer and its file."
  (declare (modes agent-shell-mode
                  agent-shell-viewport-view-mode
                  agent-shell-viewport-edit-mode
                  konix/agent-shell-workspace-mode))
  (interactive)
  (let* ((here (bound-and-true-p konix/agent-shell-workspace-mode))
         (writer (if here
                     konix/agent-shell-workspace--writer-buffer
                   (konix/agent-shell--current-shell-or-error)))
         (file (or (if here
                       buffer-file-name
                     (konix/agent-shell-workspace-file writer))
                   (user-error "No workspace here to take away")))
         (buffer (find-buffer-visiting file)))
    (unless (yes-or-no-p (format "Take %s away for good, its writer with it? "
                                 (file-name-nondirectory file)))
      (user-error "Left where it was"))
    (when (buffer-live-p writer)
      (konix/agent-shell-workspace--forget writer)
      (run-with-timer 0 nil
                      (lambda ()
                        (konix/mcp-server--kill-buffers (list writer)))))
    (when (buffer-live-p buffer)
      (let ((kill-buffer-query-functions nil))
        (kill-buffer buffer)))
    (when (file-exists-p file)
      (delete-file file t))
    (message "%s is gone" (file-name-nondirectory file))))

a bound session says so

rule
and a session writing into a document says so where the user talks to it.
how
the theme’s own background is kept, at its own lightness, with its hue turned two hundred and twenty degrees round the wheel.
how
and a grey is lifted to two hundredths of saturation first, since turning the hue of a grey leaves it grey.
(defun konix/agent-shell-workspace--tint-colour ()
  "Return the tint a bound session's background carries."
  (let ((base (face-background 'default nil t)))
    (when (and base (color-defined-p base))
      (pcase-let* ((`(,r ,g ,b) (color-name-to-rgb base))
                   (`(,h ,s ,l) (color-rgb-to-hsl r g b))
                   (`(,r2 ,g2 ,b2)
                    (color-hsl-to-rgb
                     (mod (+ h (/ konix/agent-shell-workspace-tint-degrees 360.0))
                          1.0)
                     (max s konix/agent-shell-workspace-tint-saturation)
                     l)))
        (color-rgb-to-hex r2 g2 b2 2)))))
what
a viewport is tinted for the session behind it, not for itself.
(defun konix/agent-shell-workspace--file-here ()
  "Return the document the session of this buffer writes into, or nil."
  (when (derived-mode-p 'agent-shell-mode
                        'agent-shell-viewport-view-mode
                        'agent-shell-viewport-edit-mode)
    (when-let ((shell (ignore-errors
                        (konix/agent-shell--current-shell-or-error))))
      (konix/agent-shell-workspace-file shell))))
how
the shell and the viewport it talks through are both tinted, each as it is set up.
(defun konix/agent-shell-workspace--tint (buffer)
  "Tint BUFFER when the session it belongs to writes into a document."
  (when (buffer-live-p buffer)
    (with-current-buffer buffer
      (buffer-face-set
       (when (konix/agent-shell-workspace--file-here)
         (list :background (konix/agent-shell-workspace--tint-colour)))))))

(defun konix/agent-shell-workspace--tint-here ()
  "Tint the buffer being set up, for `agent-shell' and its viewport modes."
  (konix/agent-shell-workspace--tint (current-buffer)))

(defun konix/agent-shell-workspace--tint-session (shell)
  "Tint everything SHELL shows through."
  (konix/agent-shell-workspace--tint shell)
  (when (fboundp 'agent-shell-viewport--buffer)
    (konix/agent-shell-workspace--tint
     (agent-shell-viewport--buffer :shell-buffer shell :existing-only t))))

(add-hook 'agent-shell-mode-hook #'konix/agent-shell-workspace--tint-here)
(add-hook 'agent-shell-viewport-view-mode-hook
          #'konix/agent-shell-workspace--tint-here)
(add-hook 'agent-shell-viewport-edit-mode-hook
          #'konix/agent-shell-workspace--tint-here)
how
the turn and that least saturation are settings, and changing either tints every session again on the spot.
(defun konix/agent-shell-workspace--retint (symbol value)
  "Set SYMBOL to VALUE, then tint every session again."
  (set-default symbol value)
  (dolist (buffer (buffer-list))
    (with-current-buffer buffer
      (when (derived-mode-p 'agent-shell-mode
                            'agent-shell-viewport-view-mode
                            'agent-shell-viewport-edit-mode)
        (konix/agent-shell-workspace--tint buffer)))))

(defcustom konix/agent-shell-workspace-tint-degrees 220
  "How far round the wheel a bound session's background hue is turned."
  :type 'integer :group 'konix
  :set #'konix/agent-shell-workspace--retint)

(defcustom konix/agent-shell-workspace-tint-saturation 0.02
  "Least colour a bound session's background carries."
  :type 'number :group 'konix
  :set #'konix/agent-shell-workspace--retint)

what binding tells the writer

what it is told

how
the writer is not merely handed a path, it is told how the two of them work and what they are working on, through the seam that survives it being mid-turn.
  • rule: and it puts a question in WORKING before it acts on it.
    • why: the user wants to know which one it is on so as to anticipate the question coming.
(defconst konix/agent-shell-workspace-briefing
  "You are now bound to %s as your workspace. Binding is done: never call
set_workspace, which is refused you and will only cost you the turn. What this
implies for you:

- Put your questions to the user there, not in this chat. One heading per question,
  each ending in a question mark: a question that asks nothing is refused.
- What only tells them something is a fact, never a question with a mark tacked on.
  Write it with set_workspace_fact, passing about with the id of the question it
  reports on, so that question links to it.
- Write them one at a time with set_workspace_question. A question you have just made
  comes back to the user: you cannot make one and take it up yourself.
- A body is bullets in intention :: text form, 120 characters each and 300 per
  question. Past that the call is refused, so say less rather than shorter.
- Address a question by its id, which %s gives you. A call naming none writes a
  new question, so pass the id whenever you mean to rewrite one.
- Read %s rather than assuming how far the user has got.
- Each comes back saying where it stands: yours, held, asked, finished, settled or
  later. Only the first two are ones you may act on.
- A priority may follow it, [#A] the highest. Weigh it when you choose which to take
  up; nothing refuses you a lower one, and you never write a priority yourself.
- later is one the user has put off. No act of yours reaches it: leave it where it
  stands, and work on something else.
- When the user answers, the question comes back to you on its own. That is your
  signal to act, not to reply.
- You name an act, each named after where it leaves the question: work, refine,
  put-down, close. %s performs one, touching nothing else.
- work before you act on anything. Until you do, every other tool is refused you.
- The one you hold is the only thing you work on. Anything else you notice on the way
  is a question to write and leave with them, never a thing to go and do.
- A place is a link, never directions. Pass file and line and the tool writes the link;
  a path spelled out in prose, or « look under x/y », is something they will not follow.
- Where a question is about a change, pass revspec with it: the revision that question
  is about, a sha or HEAD~1 or main..HEAD. That is what makes its place come out as the
  hunk it points at rather than as plain lines, and questions are generally about
  separate commits, so each carries its own.
- close it once the work is done and only their agreement is left. That is how a
  question ends, and it is the act you will forget: refine hands the work back to
  you, close hands them the finished thing.
- closing takes two words: close asks you whether anything is still to be done, and
  close-for-sure is what closes. Read that asking rather than repeating yourself: it
  is the moment to see whether refining is what you meant.
- refine as soon as what the one you hold asks is unclear, and do not hesitate: a
  guess costs both of you the work it sends you off to do, where one refined question
  costs them a sentence. The question you hold is the whole of what you are doing.
- Settling is the user's own move, refused you, and only a settled question can be
  deleted.
- One question is held at a time, so put down the one you hold before you take up
  another.
- Work on something else while the user reads. Neither waits for the other.
- With nothing left that is yours, the turn is cut short for you, so do not invent a
  question to have something to do. Their next word starts you again with the
  workspace in hand. Never wait inside Emacs, which would stop it reading their keys.
- Never read the workspace file yourself; %s gives you everything in it."
  "What a writer is told about how the work goes here, whatever the work is.")
rule
and a writer bound to a workspace with nothing in it is told nothing at all, the briefing riding whatever it is first told instead.
  • why: the briefing was the only thing there to answer, so the writer set about answering it, and there was no work in the workspace to be had.
  • how: it is owed until it is given, so the first word the workspace has for the writer carries it, whichever word that turns out to be.
  • therefore: binding costs a turn only where there is a turn’s work to do.
  • rule: and the user is left in the workspace instead, which is where the first subject is raised.
    • why: they bound it to start work, and nothing is going to happen until they say what the work is.
how
the listing names neither the workspace’s own path nor the goal line above the questions, so the briefing names both.
rule
and the goal is worded once, wherever the writer is told it, and names the tool that asks.
  • why: « in case of doubt, ask the user » is what a writer answers in the chat, having nothing there telling it where asking happens.
  • therefore: the binding and every nudge say the same words, there being one wording to say.
(defconst konix/agent-shell-workspace-goal-said
  (concat "Our goal: %s"
          "\n\nWhatever you and the user do must get closer to reaching that goal."
          " In case of doubt, ask the user with set_workspace_question.")
  "What a writer is told about the goal, its own goal filled in.")

(defun konix/agent-shell-workspace-binding-briefing (file &optional goal)
  "Return what a writer is told when FILE becomes its workspace, to work on GOAL."
  (let ((listing (concat konix/mcp-server-read-only-prefix
                         "list_workspace_questions"))
        (acting "set_workspace_state"))
    (concat (format konix/agent-shell-workspace-briefing
                    file listing listing acting listing)
            (when (and goal (not (string-empty-p (string-trim goal))))
              (concat "\n\n"
                      (format konix/agent-shell-workspace-goal-said
                              (string-trim goal)))))))

the goal that travels with it

what
a goal of the user’s travels with it, when the user has one to give.
  • why: a session that starts here needs to know what the work is, or its first questions are aimed at nothing.
  • scope: a writer already worked with for a while needs only the process, so an empty one is taken as none.
what
the goal is written into the workspace as well, where the writer reads it.
  • why: when the writer slips, the user wants to say « read the goal again ».
(defun konix/agent-shell-workspace--forget-goal ()
  "Take this buffer's GOAL line off, there being none to declare."
  (save-excursion
    (goto-char (point-min))
    (when (re-search-forward "^#\\+GOAL: *.*$" nil t)
      (delete-region (match-beginning 0) (min (point-max) (1+ (match-end 0)))))))

(defun konix/agent-shell-workspace--ensure-goal (goal)
  "Declare GOAL in this buffer, or take the line off when GOAL is empty."
  (if (and goal (not (string-empty-p (string-trim goal))))
      (konix/agent-shell-workspace--ensure-keyword "GOAL" (string-trim goal))
    (konix/agent-shell-workspace--forget-goal)))
what
w is the user setting it, on a workspace already bound.
  • why: the user edits the goal, not the writer.
  • how: it reads the goal at the prompt, offering whatever the line says now, and writes that line.
  • rule: and clearing the prompt takes the goal away.
    • why: the user asks how a goal is removed, and there was no way.
    • therefore: the one key both sets and unsets, so nothing else has to be found.
what
W is the user saying it.
  • scope: a workspace naming no goal refuses the key, there being nothing to send the writer back to.
  • scope: on a writer at work it goes over at the turn’s end rather than waiting to be read back, being in no file to read.
  • how: the echo area says whether it was sent or is queued for the turn’s end.
(defconst konix/agent-shell-workspace-goal-guidance
  "Read the goal again, the GOAL line above the questions, and pick up from there."
  "What a writer is told when it has slipped off the goal.")

(defun konix/agent-shell-workspace-set-goal (goal)
  "Declare GOAL the goal of this workspace, and save so the writer reads it."
  (interactive
   (list (read-from-minibuffer
          "Goal: " (konix/agent-shell-workspace--keyword "GOAL"))))
  (konix/agent-shell-workspace--write
    (konix/agent-shell-workspace--ensure-goal goal))
  (message "%s" (if (string-empty-p (string-trim goal))
                    "The goal is gone, this workspace naming none"
                  (format "The goal is now: %s" (string-trim goal)))))

(defun konix/agent-shell-workspace-goal-again ()
  "Tell the writer to read the goal again."
  (interactive)
  (unless (konix/agent-shell-workspace--keyword "GOAL")
    (user-error "This workspace names no goal"))
  (let ((writer (konix/agent-shell-workspace--target-writer)))
    (message
     (if (eq t (konix/agent-shell-workspace--submit
                writer konix/agent-shell-workspace-goal-guidance))
         "Sent the writer back to the goal"
       "Queued — the writer goes back to the goal at the turn's end"))))

what can be bound

rule
a session without the workspace tools cannot be bound at all.
  • why: binding a session that has no way to write there would make no sense.
  • rule: binding whitelists the server for the session.
    • why: answering must not stop at a permission dialog.
(defconst konix/agent-shell-workspace-server-name "konix-emacs-workspace"
  "MCP server holding the tools a bound writer answers with.")

(defun konix/agent-shell-workspace--whitelist-answering-tools (shell)
  "Auto-approve the server's tools in SHELL, on its ephemeral session axis.
Matched on the tool title, which for an MCP tool is `mcp__SERVER__TOOL'."
  (with-current-buffer shell
    (konix/agent-shell-whitelist-tool
     (format "^mcp__%s__" konix/agent-shell-workspace-server-name)
     "the workspace tools a bound writer answers with"
     'session)))
rule
naming a file that is not there yet makes it, so a workspace can be bound before there is one.
  • scope: either door makes it the same way, the user’s prompt and the writer’s own call alike.
  • rule: and the directory it goes in is made with it.
    • why: the place the prompt offers is one a project has no reason to have yet.
rule
the prompt offers a .ws directory of the project the session works in, with the name left to type and the .org already there.
  • why: a workspace is of the project, and the one place for all of them is one decision the user does not have to take again.
  • how: point waits before the .org, so the whole of what is typed is the name.
rule
and a workspace already naming a goal offers it at the prompt.
  • why: a goal already in the workspace must be prefilled.
  • therefore: binding a second time keeps the goal by pressing return, changes it by editing, and takes it away by clearing — the three the w key gives.
(defconst konix/agent-shell-workspace-directory ".ws"
  "Where under a project the prompt offers to put a workspace.")

(defun konix/agent-shell-workspace--make-unless-there (file)
  "Make FILE an empty workspace, and its directory, unless they are there."
  (unless (file-readable-p file)
    (make-directory (file-name-directory file) t)
    (write-region (format "#+TITLE: %s\n\n" (file-name-base file))
                  nil file)))

(defun konix/agent-shell-workspace--read-file-name ()
  "Read where a workspace goes, offering this project's own place for it."
  (let ((where (file-name-as-directory
                (expand-file-name
                 konix/agent-shell-workspace-directory
                 (konix/agent-shell-workspace--project-of (current-buffer))))))
    (expand-file-name
     (minibuffer-with-setup-hook
         (lambda () (search-backward ".org" nil t))
       (read-file-name "Workspace: " where nil nil ".org")))))

(defun konix/agent-shell-workspace-bind (file &optional goal)
  "Bind FILE as the workspace of the agent-shell this is called from, on GOAL."
  (declare (modes agent-shell-mode
                  agent-shell-viewport-view-mode
                  agent-shell-viewport-edit-mode))
  (interactive
   (progn
     (unless (member konix/agent-shell-workspace-server-name
                     (konix/agent-shell-mcp-session-server-names))
       (user-error "This session has no %s tools to answer with"
                   konix/agent-shell-workspace-server-name))
     (let ((file (konix/agent-shell-workspace--read-file-name)))
       (unless (string-suffix-p ".org" file)
         (user-error "A workspace has to be an Org file: %s" file))
       (list file
             (read-string "What the work is (empty for none): "
                          (konix/agent-shell-workspace--goal-in file))))))
  (let ((shell (konix/agent-shell--current-shell-or-error))
        (file (expand-file-name file)))
    (konix/agent-shell-workspace--make-unless-there file)
    (konix/agent-shell-workspace--write-file file
      (konix/agent-shell-workspace--ensure-well-formed)
      (konix/agent-shell-workspace--ensure-session-link shell)
      (konix/agent-shell-workspace--ensure-goal goal))
    (konix/agent-shell-workspace--remember shell file)
    (let ((buffer (konix/agent-shell-workspace--show file shell t)))
      (if (konix/agent-shell-workspace--writer-done-p file)
          (progn
            (pop-to-buffer buffer)
            (message "Workspace: %s — nothing in it yet, so raise the first subject"
                     file))
        (konix/agent-shell-workspace--submit shell)
        (message "Workspace: %s" file)))))

coming back to it

rule
and a session that comes back — reloaded, or resumed days later — comes back bound the way it was: the tools whitelisted again, the steering installed again, the document open and tinted again.
scope
everything binding does except telling the writer about it, which it was told once and carries in the conversation it is resuming.
how
binding a workspace is what puts all of that around a session, so coming back is binding it again to what the store held.
(defun konix/agent-shell-workspace--restore ()
  "Bind this shell back to the workspace of the session it settles on."
  (konix/agent-shell-session-binding-restore
   konix/agent-shell-workspace--binding))

(add-hook 'agent-shell-mode-hook
          #'konix/agent-shell-workspace--restore)

how the user walks them

what
walking to a question opens it and folds the bodies of the others, so nothing nudges the user into reading them.
  • why: focusing on one question at a time, with the noise gone, is what makes answering efficient.
  • what: their headings stay in view, so the whole set is still there at a glance.

the view a key leaves behind

rule
only what waits on the user stays open: a question in any other state is folded, a fact is folded, and so is what the user said under a question.
  • why: a question the user is done reading, or one the writer holds, must fold by itself, and their own answer is the one thing there is no reason to read again.
  • how: the states say which is which, so the folding is read off the file rather than remembered from the keystroke that caused it.
    • therefore: it holds through a rewrite, which reads the file again and loses every fold the user had.
  • scope: the heading itself stays in view whatever the state, the whole set being there at a glance.
  • rule: and the view is settled by folding the whole workspace and opening what the one heading point stands in says itself, and that only while it waits on the user: a question of theirs, or a fact they have not read.
    • why: the walk lands them on a fact left unread as readily as on a question, and it landed on one shut, which reads as an empty workspace.
    • why: folding heading by heading turned on what each already was, so the same keystroke folded one time and not the next.
    • how: everything is shown before it is folded, since folding to an overview reads the folds already there and inherits whatever they got wrong.
    • how: what is opened is the run of lines from the heading to the first answer, so what stays folded does not turn on how many answers there are.
    • therefore: one call settles it from nothing, and every gesture ends in that call.
  • rule: and every change the user’s keys make goes through one write, which shows the workspace whole before it touches a character and folds it again after.
    • why: writing into a folded workspace leaves folds org cannot make sense of afterwards, and no amount of folding after the fact repairs them.
    • therefore: the fold state is never something a write has to be careful about, and no keystroke can leave the workspace open behind it.
  • rule: and TAB is org’s own, taken over for nothing.
    • why: it was taken over so that a question opened without its answers, and the price was every other thing org’s cycling does: a block, a drawer, a list.
    • therefore: the answers do come back into view on it, and settling the view folds them away again.
  • rule: and a fact the user had open stays open across a write of theirs.
    • why: they were reading one when they sent the writer work, and it shut under them.
    • scope: facts alone, a question’s own state saying whether it is open.
    • scope: and not one they have since read, saying so being the end of reading it.
  • rule: and what the user was reading, standing in it, is left in view.
    • why: the user opens something to read it, and folding it under them takes it away mid-sentence.
  • rule: and the pictures are shown again as the view is settled.
    • why: the file says it opens with them shown, and a rewrite read back under the user keeps the modes, so nothing opens it afresh to show the new ones.
  • rule: and point standing in an answer comes back to the question it sits under.
    • why: a fold cannot hide the line point is on, so the answer point was in stayed in view however well it was folded.
    • therefore: the view is always about a question, which is the only thing there is to walk to or to answer.
(defun konix/agent-shell-workspace--show-its-words ()
  "Reveal the words of the question at point, the answers under it left folded."
  (save-excursion
    (org-back-to-heading t)
    (let* ((limit (konix/agent-shell-workspace--question-end))
           (end (save-excursion
                  (forward-line 1)
                  (if (re-search-forward "^\\*\\* " limit t)
                      (1- (match-beginning 0))
                    (1- limit)))))
      (when (> end (line-end-position))
        (org-fold-region (line-end-position) end nil 'outline)))))

(defun konix/agent-shell-workspace--waiting-on-the-user-p ()
  "Non-nil when the heading point stands in is the user's to read."
  (or (member (org-get-todo-state)
              konix/agent-shell-workspace-users-keywords)
      (and (konix/agent-shell-workspace--fact-at-point-p)
           (not (konix/agent-shell-workspace--read-p)))))

(defun konix/agent-shell-workspace--in-an-answer-p ()
  "Non-nil when point stands in something the user said under a question."
  (and (not (org-before-first-heading-p))
       (equal 2 (save-excursion
                  (konix/agent-shell-workspace--goto-heading)
                  (org-current-level)))))

(defun konix/agent-shell-workspace-focus-question ()
  "Fold the workspace, and open what point stands in while it waits on the user.
Whatever the user was reading, standing in it, is left in view."
  (interactive)
  (let ((reading (and (not (invisible-p (line-beginning-position)))
                      (point-marker))))
    (when (konix/agent-shell-workspace--in-an-answer-p)
      (konix/agent-shell-workspace--goto-question))
    (org-cycle-overview)
    (unless (org-before-first-heading-p)
      (when (konix/agent-shell-workspace--waiting-on-the-user-p)
        (konix/agent-shell-workspace--show-its-words)))
    (konix/agent-shell-workspace--in-view-again
     konix/agent-shell-workspace--reading)
    (org-link-preview-region)
    (when (and reading (invisible-p (marker-position reading)))
      (save-excursion
        (goto-char reading)
        (org-fold-show-context)))
    (when reading (set-marker reading nil))))

the keys

rule
the buffer is read-only.
  • rule: and it is claimed again after a rewrite has read the file back.
    • why: reading it back keeps the modes, so nothing settles them afresh, and it came back writable.
rule
the keys are the ones the user already presses to read a conversation with a writer, key for key.
  • scope: wherever the workspace has something of its own to do, the key runs its command; the others are left as they are.
rule
SPC leaves a question by reading past it, and M-n and M-p step to whatever waits on the user next.
  • rule: and f and b step the same way, forwards and back.
    • why: stepping is what the user does most here, and it wanted a key rather than a chord.
rule
and the keymap is emptied before it is filled, so what the note stops binding stops being bound.
  • why: a key taken out of the note stayed live until Emacs was restarted, the map being a variable a reload leaves alone.
  • how: the map keeps its identity and loses its bindings, the mode holding on to the same object.
(defvar konix/agent-shell-workspace-mode-map (make-sparse-keymap)
  "Keymap of `konix/agent-shell-workspace-mode'.")

(setcdr konix/agent-shell-workspace-mode-map nil)

(let ((map konix/agent-shell-workspace-mode-map))
    (define-key map (kbd "SPC") #'konix/agent-shell-workspace-scroll-or-track)
    (define-key map (kbd "DEL") #'scroll-down-command)
    (define-key map "g" #'beginning-of-buffer)
    (define-key map "<" #'beginning-of-buffer)
    (define-key map "G" #'end-of-buffer)
    (define-key map ">" #'end-of-buffer)
    (define-key map (kbd "M-n") #'konix/agent-shell-workspace-next-question)
    (define-key map (kbd "M-p") #'konix/agent-shell-workspace-previous-question)
    (define-key map "f" #'konix/agent-shell-workspace-next-question)
    (define-key map "b" #'konix/agent-shell-workspace-previous-question)
    (define-key map "y" #'konix/agent-shell-workspace-answer-yes)
    (define-key map "n" #'konix/agent-shell-workspace-answer-no)
    (define-key map "?" #'konix/agent-shell-workspace-answer-what)
    (define-key map "r" #'konix/agent-shell-workspace-answer)
    (define-key map "R" #'konix/agent-shell-workspace-answer)
    (define-key map "P" #'konix/agent-shell-pop-to-buffer)
    (define-key map "O" #'konix/agent-shell-workspace-goto-writer)
    (define-key map "a" #'konix/agent-shell-workspace-goto-writer)
    (define-key map "o" #'org-open-at-point)
    (define-key map (kbd "RET") #'konix/agent-shell-workspace-open-at-point)
    (define-key map "d" #'konix/agent-shell-workspace-goto-diff)
    (define-key map "t" #'konix/agent-shell-workspace-done-with-it)
    (define-key map "k" #'konix/agent-shell-workspace-drop)
    (define-key map (kbd "C-k") #'konix/agent-shell-workspace-drop-at-once)
    (define-key map "K" #'konix/agent-shell-workspace-clean)
    (define-key map "F" #'konix/agent-shell-workspace-clean-facts)
    (define-key map "p" #'konix/agent-shell-workspace-pin)
    (define-key map "e" #'konix/agent-shell-workspace-edit)
    (define-key map (kbd "M-<up>") #'konix/agent-shell-workspace-move-up)
    (define-key map (kbd "M-<down>") #'konix/agent-shell-workspace-move-down)
    (define-key map "S" #'konix/agent-shell-workspace-steering-menu)
    (define-key map "B" #'konix/agent-shell-workspace-blacklist-menu)
    (define-key map "W" #'konix/agent-shell-workspace-whitelist-menu)
    (define-key map (kbd "C-w") #'konix/agent-shell-workspace-goal-again)
    (define-key map "," #'konix/agent-shell-workspace-set-priority)
    (define-key map "m" #'konix/agent-shell-workspace-put-off)
    (define-key map "w" #'konix/agent-shell-workspace-set-goal)
    (define-key map "U" #'konix/claude-code-usage)
    (define-key map "T" #'konix/mcp-server-show-spawn-tree)
    (define-key map (kbd "M-RET") #'konix/agent-shell-workspace-add-subject)
    (define-key map "q" #'quit-window))

what the mode carries

rule
the map is made once and filled in place, so loading the note again is what refreshes the keys.
  • why: whatever is needed to refresh the bindings should be the loading itself.
  • what: a map built afresh each time leaves a buffer already in the mode holding the one it started with.
how
the mode that carries those keys keeps the reading view across a rewrite.
rule
and it keeps org from logging a state change here.
  • why: it is a custom of the user’s, and it must be neutralised in that buffer.
  • what: the log lands after the keystroke, when the buffer is read-only again, so it errors there rather than where the key ran.
rule
and killing the workspace offers to kill its writer with it.
  • why: the two are done with together, and the writer left behind is a session nobody is reading.
  • scope: it only offers, the workspace going whatever the answer is.
  • rule: and the other way round it asks nothing: the writer killed takes its workspace with it.
    • why: a workspace whose writer is gone has nobody left to answer it.
(defun konix/agent-shell-workspace--no-logging ()
  "Keep org from logging a state change in this buffer."
  (setq-local org-todo-log-states nil))

(defun konix/agent-shell-workspace--maybe-kill-writer ()
  "Offer to kill this workspace's writer along with it.
Returns t whatever the answer, the workspace going either way."
  (let ((writer konix/agent-shell-workspace--writer-buffer))
    (when (and (buffer-live-p writer)
               (yes-or-no-p (format "Kill %s, the writer of this workspace, too? "
                                    (buffer-name writer))))
      (run-with-timer 0 nil
                      (lambda ()
                        (konix/mcp-server--kill-buffers (list writer))))))
  t)

(defun konix/agent-shell-workspace--kill-with-writer ()
  "Kill the workspace this writer publishes into, the writer itself being killed."
  (when-let* ((file (konix/agent-shell-workspace-file (current-buffer)))
              (buffer (find-buffer-visiting file)))
    (run-with-timer 0 nil
                    (lambda ()
                      (when (buffer-live-p buffer)
                        (kill-buffer buffer))))))

(define-minor-mode konix/agent-shell-workspace-mode
  "Walk the questions a writer has put to you.

\\{konix/agent-shell-workspace-mode-map}"
  :lighter " Workspace"
  :keymap konix/agent-shell-workspace-mode-map
  (setq buffer-read-only konix/agent-shell-workspace-mode)
  (if konix/agent-shell-workspace-mode
      (progn
        (konix/agent-shell-workspace--no-logging)
        (konix/agent-shell-workspace--colour-keywords)
        (add-hook 'after-revert-hook
                  #'konix/agent-shell-workspace--after-revert nil t)
        (add-hook 'kill-buffer-query-functions
                  #'konix/agent-shell-workspace--maybe-kill-writer nil t)
        (add-hook 'window-buffer-change-functions
                  #'konix/agent-shell-workspace--land-in-window nil t)
        (add-hook 'window-selection-change-functions
                  #'konix/agent-shell-workspace--land-in-window nil t))
    (remove-hook 'after-revert-hook
                 #'konix/agent-shell-workspace--after-revert t)
    (remove-hook 'kill-buffer-query-functions
                 #'konix/agent-shell-workspace--maybe-kill-writer t)
    (remove-hook 'window-buffer-change-functions
                 #'konix/agent-shell-workspace--land-in-window t)
    (remove-hook 'window-selection-change-functions
                 #'konix/agent-shell-workspace--land-in-window t)))

opened by hand or by the writer

rule
opening the file by hand gives the same buffer as the writer publishing it does.
rule
and a workspace is never the user’s to type in, whatever its name and however it was opened.
  • why: what the user changes there by hand nothing tells the writer about, so it would sit unread.
  • how: the claim is made wherever a major mode is settled, not only where a file is opened, so a mode set again over the top claims it again.
    • therefore: a plain revert, which settles the mode afresh and takes every local thing off with it, leaves the buffer read-only all the same.
  • scope: it keys on the file saying it is a workspace, so a name of the user’s choosing is claimed as readily as the default one.
  • rule: and the session it answers survives that, being marked to outlive a mode.
    • what: otherwise the workspace forgets which writer it talks to and asks.
(defun konix/agent-shell-workspace-org-mode ()
  "Org, plus `konix/agent-shell-workspace-mode'.
What a workspace file opens as."
  (org-mode)
  (konix/agent-shell-workspace-mode 1))

(add-to-list 'auto-mode-alist
             (cons (concat (regexp-quote konix/agent-shell-workspace-default-name)
                           "\\'")
                   #'konix/agent-shell-workspace-org-mode))

(defun konix/agent-shell-workspace--claim-if-workspace ()
  "Turn the workspace mode on where this file says it is one."
  (when (and (derived-mode-p 'org-mode)
             (not (bound-and-true-p konix/agent-shell-workspace-mode))
             (save-excursion
               (goto-char (point-min))
               (re-search-forward "^#\\+WORKSPACE:" nil t)))
    (konix/agent-shell-workspace-mode 1)))

(add-hook 'find-file-hook #'konix/agent-shell-workspace--claim-if-workspace)
(add-hook 'after-change-major-mode-hook
          #'konix/agent-shell-workspace--claim-if-workspace)
what
the writer rewrites the file under the user while the user reads, so the reading view is restored by the mode rather than by the writer’s good manners.
  • what: reading the file’s keywords again is what org does on the way, and it works the logging out from them, so the logging is switched off once more each time.
(defun konix/agent-shell-workspace--after-revert ()
  "Read the file's own keywords again, take the logging back off, restore the view."
  (org-set-regexps-and-options)
  (konix/agent-shell-workspace-mode 1)
  (konix/agent-shell-workspace--no-logging)
  (konix/agent-shell-workspace--colour-keywords)
  (konix/agent-shell-workspace-focus-question))

stepping from one to the next

what
walking to the next thing passes over whatever is not the user’s — a question handed back, a fact they have read — and wraps round rather than stopping at the end.
  • scope: it puts the one it lands on at the top of the window only where the workspace is on screen, landing in a buffer nothing shows yet being a landing too.
(defun konix/agent-shell-workspace--land-on (position)
  "Stand on POSITION, open what is there, and put it at the top of the window."
  (goto-char position)
  (konix/agent-shell-workspace-focus-question)
  (when-let ((window (get-buffer-window (current-buffer))))
    (with-selected-window window (recenter 0)))
  position)

(defun konix/agent-shell-workspace--goto-question-matching (regexp &optional backwards)
  "Move to the next heading matching REGEXP from point on, wrapping round, and open it.
BACKWARDS looks the other way.  Return where it landed, or nil when none matches."
  (let* ((position (or (save-excursion
                         (and (konix/agent-shell-workspace--search-state
                               regexp nil backwards)
                              (match-beginning 0)))
                       (save-excursion
                         (goto-char (if backwards (point-max) (point-min)))
                         (and (konix/agent-shell-workspace--search-state
                               regexp nil backwards)
                              (match-beginning 0))))))
    (when position
      (konix/agent-shell-workspace--land-on position))))
what
the facts are a run of their own, walked the same way: the ones they have not read, in the order they stand.
(defun konix/agent-shell-workspace--facts ()
  "Return where each fact begins, in the order they stand in the workspace."
  (let (facts)
    (save-excursion
      (goto-char (point-min))
      (unless (org-at-heading-p)
        (outline-next-heading))
      (while (org-at-heading-p)
        (when (konix/agent-shell-workspace--fact-at-point-p)
          (push (point) facts))
        (outline-next-heading)))
    (nreverse facts)))

(defun konix/agent-shell-workspace--read-p ()
  "Non-nil when the user has said they read the fact at point."
  (member org-archive-tag (org-get-tags nil t)))

(defun konix/agent-shell-workspace--unread-facts ()
  "Return where each fact the user has not read begins."
  (seq-remove (lambda (where)
                (save-excursion
                  (goto-char where)
                  (konix/agent-shell-workspace--read-p)))
              (konix/agent-shell-workspace--facts)))

(defun konix/agent-shell-workspace--goto-unread-fact ()
  "Move to the next fact the user has not read, wrapping round, nil where none is."
  (let* ((facts (konix/agent-shell-workspace--unread-facts))
         (here (point))
         (next (or (seq-find (lambda (where) (> where here)) facts)
                   (car facts))))
    (when next
      (konix/agent-shell-workspace--land-on next))))
rule
and the two runs are one walk, in the user’s own order: what asks them first, then what waits on their agreement, then what they have not read.
  • why: what waits on the user is one thing to them, and a walk that only knew the questions stepped over the facts they had not read.
  • why: and answering is what unblocks the writer, so it comes before agreeing and before reading.
  • how: within each of the three they stand in the order they stand in the file, and the walk steps to the next of the three runs, or the one before it going back, and wraps round.
  • therefore: everything that ends on a landing — stepping, answering, closing, dropping — leaves them on whatever waits next in that order.
(defun konix/agent-shell-workspace--waiting-rank ()
  "Return where the heading at point comes in the user's own order, or nil."
  (let ((state (konix/agent-shell-workspace--state-at-point)))
    (cond
     ((equal state konix/agent-shell-workspace-refine-keyword) 0)
     ((equal state konix/agent-shell-workspace-closing-keyword) 1)
     ((and (konix/agent-shell-workspace--fact-at-point-p)
           (not (konix/agent-shell-workspace--read-p)))
      2))))

(defun konix/agent-shell-workspace--waiting-on-the-user ()
  "Return where each heading waiting on the user begins, in the user's own order."
  (let (found)
    (save-excursion
      (goto-char (point-min))
      (unless (org-at-heading-p)
        (outline-next-heading))
      (while (org-at-heading-p)
        (when-let* ((rank (konix/agent-shell-workspace--waiting-rank)))
          (push (cons rank (point)) found))
        (outline-next-heading)))
    (mapcar #'cdr
            (sort (nreverse found)
                  (lambda (a b)
                    (if (equal (car a) (car b))
                        (< (cdr a) (cdr b))
                      (< (car a) (car b))))))))

(defun konix/agent-shell-workspace--goto-waiting (&optional backwards)
  "Move to what waits on the user next, wrapping round, and open it.
BACKWARDS looks the other way.  Return where it landed, nil where nothing waits."
  (let* ((all (konix/agent-shell-workspace--waiting-on-the-user))
         (here (save-excursion
                 (unless (org-before-first-heading-p)
                   (org-back-to-heading t))
                 (point)))
         (standing (seq-position all here))
         (next (cond
                ((and standing backwards)
                 (nth (mod (1- standing) (length all)) all))
                (standing
                 (nth (mod (1+ standing) (length all)) all))
                (backwards (car (last all)))
                (t (car all)))))
    (when next
      (konix/agent-shell-workspace--land-on next))))

(defun konix/agent-shell-workspace--goto-settled-question ()
  "Move to the next question the user has settled, wrapping round, and open it."
  (konix/agent-shell-workspace--goto-question-matching
   konix/agent-shell-workspace-settled-regexp))
rule
and the user puts the headings in the order they want them read, M-<up> and M-<down> moving the one they stand in past its neighbour.
  • why: the walk goes down the file within each run, so the order they are in is the order they come up in.
  • how: it moves whichever heading point stands in, from anywhere inside it, as every other key here acts on the one point stands in.
  • scope: it is a change of the user’s own, so it is written and saved the way their every other change is.
(defun konix/agent-shell-workspace-move-up ()
  "Move what point stands in up past the heading before it."
  (interactive)
  (konix/agent-shell-workspace--write
    (konix/agent-shell-workspace--goto-heading)
    (org-move-subtree-up)))

(defun konix/agent-shell-workspace-move-down ()
  "Move what point stands in down past the heading after it."
  (interactive)
  (konix/agent-shell-workspace--write
    (konix/agent-shell-workspace--goto-heading)
    (org-move-subtree-down)))
rule
stepping stops only where the user owes the next move.
(defun konix/agent-shell-workspace-next-question ()
  "Move to what waits on the user next, in their own order."
  (interactive)
  (konix/agent-shell-workspace--goto-waiting))

(defun konix/agent-shell-workspace-previous-question ()
  "Move back to what waited on the user before this one, in their own order."
  (interactive)
  (konix/agent-shell-workspace--goto-waiting t))
what
RET opens where point stands: on a line of a block a heading carries, the file at the line that line stands for.
  • why: reading the code in the question, the user wants the file itself, and the link above the block is not where they are looking.
  • how: a hunk says where it starts in its header and counts the way the change counts, a removed line advancing nothing; plain lines carry their own numbering.
  • how: the file is the one the place above the block names, so a heading naming several places opens the right one for each.
  • scope: anywhere else it follows the link on the line, as o does.
what
o is bound to org-open-at-point itself, with nothing of this note’s in between.
  • what: so it follows whatever link point is on — a place, a fact by its id, a note, a page — and on a heading it offers the ones that heading holds.
  • therefore: reaching a fact from the question and the question back from the fact is the same key as reaching the code, and no code of this note’s decides any of it.
(defun konix/agent-shell-workspace--place-here ()
  "Return (FILE . LINE) for the file line the block line point is on stands for."
  (save-excursion
    (let ((here (line-beginning-position))
          switches body file line)
      (when (re-search-backward "^#\\+begin_src\\([^\n]*\\)$" nil t)
        (setq switches (match-string 1)
              body (save-excursion (forward-line 1) (point))
              file (save-excursion
                     (when (re-search-backward
                            konix/agent-shell-workspace-link-regexp nil t)
                       (match-string 1))))
        (cond
         ((string-match "-n +\\([0-9]+\\)" switches)
          (setq line (string-to-number (match-string 1 switches)))
          (goto-char body)
          (while (< (point) here)
            (setq line (1+ line))
            (forward-line 1)))
         ((string-match-p "diff" switches)
          (goto-char body)
          (when (looking-at "^@@ -[0-9]+\\(?:,[0-9]+\\)? \\+\\([0-9]+\\)")
            (setq line (string-to-number (match-string 1)))
            (forward-line 1)
            (while (< (point) here)
              (unless (looking-at "^-")
                (setq line (1+ line)))
              (forward-line 1)))))
        (when (and file line) (cons file line))))))

(defun konix/agent-shell-workspace-open-at-point ()
  "Open where point stands: the file a diff line stands for, or the link on it."
  (interactive)
  (if-let ((place (konix/agent-shell-workspace--place-here)))
      (let ((buffer (find-file-noselect (car place))))
        (pop-to-buffer buffer)
        (goto-char (point-min))
        (forward-line (1- (cdr place)))
        (recenter))
    (org-open-at-point)))

which question is next

rule
and reading past the last question takes the user to whichever writer is waiting next, the way it does in a conversation.
rule
and arriving at a workspace lands the user on a question that is theirs, however they got there.
  • why: they come to a workspace for what is theirs in it, so landing anywhere else is landing on the wrong thing.
  • scope: point is left alone where it already stands on one of theirs, so coming back mid-answer picks up where it was.
    • scope: and standing above every question is standing in none, so arriving at the top of the file lands all the same.
  • how: it hangs off a window coming to show the workspace, or coming to be the one the user is in — between them, every way of arriving.
    • why: hanging it off the step itself moved point before the window did, and the window put it back where it had been.
    • why: and a workspace already on screen changes no window’s buffer, so arriving at one is the window being selected and nothing else.
  • rule: and a rewrite settling under the user is no arrival, so it lands nobody.
    • why: the one they were reading stops being theirs the moment the writer takes it back, and hunting for another then takes the page from under them.
    • how: a rewrite says so until the user’s next keystroke, which is what tells a window stirred by the writer from one the user came to.
    • rule: and it says which workspace it is settling in, so the others land as ever.
      • why: the user keeps several writers at work, and one of them rewriting held every workspace still while it did.
  • scope: with nothing of theirs there it moves nothing.
(defun konix/agent-shell-workspace--land-on-a-users-question (&rest _)
  "Put point on a question of the user's, unless it already stands on one."
  (when (and (bound-and-true-p konix/agent-shell-workspace-mode)
             (save-excursion
               (goto-char (point-min))
               (konix/agent-shell-workspace--search-state
                konix/agent-shell-workspace-users-regexp)))
    (let ((state (unless (org-before-first-heading-p)
                   (save-excursion
                     (konix/agent-shell-workspace--goto-question)
                     (org-get-todo-state)))))
      (unless (member state konix/agent-shell-workspace-users-keywords)
        (goto-char (point-min))
        (konix/agent-shell-workspace--goto-waiting)))))

(defvar konix/agent-shell-workspace--settling nil
  "The workspace a rewrite is settling in, until the user's next keystroke.")

(defun konix/agent-shell-workspace--settling-now (buffer)
  "Say that a rewrite is settling in BUFFER, so no window it stirs is an arrival."
  (setq konix/agent-shell-workspace--settling buffer))

(defun konix/agent-shell-workspace--done-settling ()
  "Let a window coming to a workspace count as an arrival again."
  (setq konix/agent-shell-workspace--settling nil))

(add-hook 'pre-command-hook #'konix/agent-shell-workspace--done-settling)

(defun konix/agent-shell-workspace--land-in-window (window)
  "Land on a question of the user's, WINDOW having come to this workspace."
  (when (and (not (eq konix/agent-shell-workspace--settling (current-buffer)))
             (window-live-p window)
             (eq (window-buffer window) (current-buffer)))
    (with-selected-window window
      (konix/agent-shell-workspace--land-on-a-users-question))))
(defun konix/agent-shell-workspace--track-next ()
  "Leave the workspace for whichever writer is waiting next."
  (when (buffer-live-p konix/agent-shell-workspace--writer-buffer)
    (with-current-buffer konix/agent-shell-workspace--writer-buffer
      (setq konix/agent-shell--seen t)))
  (when (and (not (bound-and-true-p tracking-buffers))
             (not (bound-and-true-p tracking-start-buffer)))
    (konix/agent-shell-track-ready-buffers t))
  (let ((old (current-buffer)))
    (tracking-next-buffer)
    (bury-buffer (unless (equal (current-buffer) old) old))))

(defun konix/agent-shell-workspace-scroll-or-track ()
  "Scroll a page, and past the end move on to the next writer waiting."
  (interactive)
  (cond
   ((and (= (window-end) (point-max)) (= (point) (point-max)))
    (konix/agent-shell-workspace--track-next))
   ((= (window-end) (point-max))
    (goto-char (point-max)))
   (t
    (scroll-up-command))))
what
closing a question moves the user on, reopening one leaves point where it stands.
  • rule: reopening one lands it in CLOSING, which is where it reached DONE from.
rule
and t on a fact says it is read, or unsays it.
  • why: it is the one key for being through with what point stands in, and having two of them made the user remember which heading they were on.
rule
and settling one wakes nobody.
  • what: settling says the user is done reading, which is news to no one, so a writer left alone stays left alone.
rule
and settling one folds it away, the walk doing it where there is one of theirs to walk to and the view being settled where it stands otherwise.
  • why: with none of theirs left there is no walk, and the question would stay open under them.
 (defun konix/agent-shell-workspace--hand-back ()
   "Hand the question at point back to the writer, and save so it sees it."
   (save-excursion
     (konix/agent-shell-workspace--goto-question)
     (konix/agent-shell-workspace--write
       (org-todo konix/agent-shell-workspace-fresh-keyword))))

 (defun konix/agent-shell-workspace--settle-question ()
   "Move the question at point to done, or back to the user where it is there."
   (let ((settling (not (equal (org-get-todo-state)
                               konix/agent-shell-workspace-done-keyword))))
     (konix/agent-shell-workspace--write
       (org-todo (if settling
                     konix/agent-shell-workspace-done-keyword
                   konix/agent-shell-workspace-closing-keyword)))
     (when settling
       (let ((here (point)))
         (end-of-line)
         (unless (konix/agent-shell-workspace--goto-waiting)
           (goto-char here))
         (konix/agent-shell-workspace-focus-question)))))

 (defun konix/agent-shell-workspace-done-with-it ()
   "Be through with what point stands in: a question closed, a fact read.
Pressed again it takes that back."
   (interactive)
   (konix/agent-shell-workspace--goto-question)
   (cond
    ((konix/agent-shell-workspace--fact-at-point-p)
     (konix/agent-shell-workspace-toggle-read))
    ((org-get-todo-state)
     (konix/agent-shell-workspace--settle-question))
    (t (user-error "This is neither a question to close nor a fact to read"))))

dropping one

rule
two keys take away what point stands in — a question, a fact, or an answer of the user’s.
  • scope: the question that answer sat under stays the writer’s, its turn unchanged by the answer going.
  • rule: either of them takes a question whatever state it stands in.
what
an interruption on its own tells the writer nothing about what changed under it.
  • how: the waking says something changed, in words the key that woke it sets.
how
point then walks on the way closing walks the user on, to the next question that is the user’s, wrapping round.
  • scope: with none left it stays where it is.
  • rule: and dropping one already settled walks on to the next settled one instead.
    • why: settled ones are dropped in a run, so the key lands the user on the next one to drop.
therefore
taking away, waking and walking on are one gesture, and a key that took only part of it would leave the user somewhere no key leaves them.
(defun konix/agent-shell-workspace--take-away (confirm guidance whatever-state)
  "Take away what point stands in, and tell the writer GUIDANCE.
CONFIRM asks the user first.  WHATEVER-STATE tells the writer wherever it had got to;
without it, only a writer working on the question is told."
  (konix/agent-shell-workspace--goto-heading)
  (let* ((on-answer (equal (org-current-level) 2))
         (end (if on-answer
                  (konix/agent-shell-workspace--answer-end)
                (konix/agent-shell-workspace--question-end)))
         (state (save-excursion
                  (when on-answer (org-up-heading-safe))
                  (org-get-todo-state)))
         (worked-on (equal state
                           konix/agent-shell-workspace-working-keyword))
         (settled (equal state konix/agent-shell-workspace-done-keyword)))
    (when (or (not confirm)
              (y-or-n-p (format "Drop \"%s\"? " (org-get-heading t t t t))))
      (konix/agent-shell-workspace--write
        (delete-region (point) end))
      (when-let* (((or whatever-state worked-on))
                  (writer (konix/agent-shell-workspace--target-writer))
                  (file (konix/agent-shell-workspace-file writer))
                  ((not (konix/agent-shell-workspace--writer-done-p file))))
        (konix/agent-shell-workspace--submit writer guidance t))
      (if settled
          (konix/agent-shell-workspace--goto-settled-question)
        (konix/agent-shell-workspace--goto-waiting)))))
what
k is the plain one.
  • why: after a long working session the user has a bunch of DONE entries and wants to clean them up.
  • why: and having sent something they finally want to retract, the user wants to simply remove the entry.
  • why: and the user believes a fact is not relevant any more, or was a mistake.
  • rule: it asks before it drops, naming what it is about to take away.
    • rule: and C-k does the same asking nothing, for a run of them.
      • why: clearing out what is settled is a run of drops, and being asked each time is the keystroke doing nothing.
    • rule: and K drops what is settled and the facts nothing reaches, all of it, in one go.
      • why: a run of drops is still a run, and clearing out is one gesture.
      • rule: and it says how many before it takes anything.
        • why: it reaches the whole workspace, which is no place to slip.
      • rule: and F takes the facts nothing reaches and leaves every question alone.
        • why: the facts pile up while the questions are still being answered.
  • rule: and it wakes the writer only when it is working on the question, whether that question went or an answer under it did.
    • why: the writer must be woken only when it needs to read the new information.
(defconst konix/agent-shell-workspace-dropped-guidance
  "What you are working on in the workspace changed under you. Read it back."
  "What a writer at work is told when what it is on changed under it.")

(defun konix/agent-shell-workspace-drop ()
  "Drop what point stands in, asking the user first."
  (interactive)
  (konix/agent-shell-workspace--take-away
   t konix/agent-shell-workspace-dropped-guidance nil))

(defun konix/agent-shell-workspace-drop-at-once ()
  "Drop what point stands in, asking nothing."
  (interactive)
  (konix/agent-shell-workspace--take-away
   nil konix/agent-shell-workspace-dropped-guidance nil))

(defun konix/agent-shell-workspace--settled-questions ()
  "Return where each question the user has settled begins."
  (let (found)
    (save-excursion
      (goto-char (point-min))
      (while (konix/agent-shell-workspace--search-state
              konix/agent-shell-workspace-settled-regexp)
        (push (match-beginning 0) found)))
    found))

(defun konix/agent-shell-workspace-pin ()
  "Pin the fact point stands on so no sweep takes it, or take the pin off."
  (interactive)
  (save-excursion
    (konix/agent-shell-workspace--goto-question)
    (unless (konix/agent-shell-workspace--fact-at-point-p)
      (user-error "A question is no fact, and stands until you settle it"))
    (let ((pinned (konix/agent-shell-workspace--pinned-p)))
      (konix/agent-shell-workspace--write
        (org-set-tags
         (if pinned
             (remove konix/agent-shell-workspace-pinned-tag (org-get-tags nil t))
           (cons konix/agent-shell-workspace-pinned-tag (org-get-tags nil t)))))
      (message (if pinned "Unpinned" "Pinned")))))

(defun konix/agent-shell-workspace--drop-unreached-facts ()
  "Drop every fact no question reaches, and return how many went."
  (let ((ids (konix/agent-shell-workspace--unlinked-facts)))
    (when ids
      (konix/agent-shell-workspace--write
        (dolist (id ids)
          (when (konix/agent-shell-workspace--goto-id id)
            (delete-region (point)
                           (konix/agent-shell-workspace--question-end))))))
    (length ids)))

(defun konix/agent-shell-workspace-clean-facts ()
  "Drop every fact no question reaches, leaving the questions alone."
  (interactive)
  (let ((orphans (length (konix/agent-shell-workspace--unlinked-facts))))
    (if (zerop orphans)
        (message "Nothing to clean: every fact is reached")
      (when (y-or-n-p (format "Drop %d fact%s nothing reaches? "
                              orphans (if (= orphans 1) "" "s")))
        (message "%d fact%s gone"
                 (konix/agent-shell-workspace--drop-unreached-facts)
                 (if (= orphans 1) "" "s"))))))

(defun konix/agent-shell-workspace-clean ()
  "Drop every question the user has settled, and every fact nothing reaches."
  (interactive)
  (let ((settled (length (konix/agent-shell-workspace--settled-questions)))
        (orphans (length (konix/agent-shell-workspace--unlinked-facts))))
    (if (zerop (+ settled orphans))
        (message "Nothing to clean: none settled, and every fact is reached")
      (when (y-or-n-p (format "Drop %d settled and %d fact%s nothing reaches? "
                              settled orphans (if (= orphans 1) "" "s")))
        (konix/agent-shell-workspace--write
          (dolist (where (konix/agent-shell-workspace--settled-questions))
            (goto-char where)
            (delete-region (point)
                           (konix/agent-shell-workspace--question-end))))
        (message "%d settled and %d fact%s gone"
                 settled
                 (konix/agent-shell-workspace--drop-unreached-facts)
                 (if (= orphans 1) "" "s"))))))

saying a fact is read

why
the facts pile up while the questions are still open, and the user who has read one had no way to say so: every key they had took it away instead.
rule
t says the fact point stands in is read, and pressing it again takes that back.
  • why: it is the key that is done with a question, and being done with a fact is reading it.
  • how: it is org’s own archive tag, so org fades the heading and keeps its subtree shut when the user cycles it open again, both of its own accord.
    • why: a mark of our own would want a face and a rule about folding, for what org already carries.
  • scope: it reaches a fact only, a question being answered rather than read.
  • rule: and the writer is told nothing of it.
    • why: a fact is the user’s to read, and what they have got through is none of the writer’s business.
  • rule: and a fact the writer rewrites comes back unread.
    • why: the new words are the ones they have not read.
rule
and saying so shuts the fact and walks the user on to the next one they have not read, wrapping round, which lands open in front of them.
  • why: reading the facts is a run of them, and the key that ends one lands the user on the one they read next.
  • how: it lands the way every walk lands, so what it lands on is opened and put at the top of the window by the one arrangement that does that.
  • rule: and with none left unread it walks them on to the next question of theirs, where closing one would leave them.
    • why: having read the last of them, what is left is what is waiting on them.
  • scope: taking it back leaves them standing where they are, having gone back to read it.
(defun konix/agent-shell-workspace-toggle-read ()
  "Say the fact point stands in is read and walk on, or take that back."
  (interactive)
  (konix/agent-shell-workspace--goto-heading)
  (unless (konix/agent-shell-workspace--fact-at-point-p)
    (user-error "A question is answered rather than read"))
  (let ((read (not (konix/agent-shell-workspace--read-p))))
    (konix/agent-shell-workspace--write
      (org-toggle-archive-tag))
    (when read
      (org-fold-hide-subtree)
      (unless (konix/agent-shell-workspace--goto-unread-fact)
        (konix/agent-shell-workspace--goto-waiting)))))

what the user raises

a heading of the user’s, and what goes under it

what
what the user types is a heading, and whatever runs past its first line is the body under it.
  • why: what the user has to say does not always fit the one line a heading allows.
  • how: it is one prompt, and the blank lines between the two are trimmed away.
what
M-RET starts the body inside the prompt, and RET sends what was typed.
  • why: it is the same key everywhere, in an answer as in a subject.
rule
a heading with nothing in it is refused.
 (defconst konix/agent-shell-workspace-prompt-map
   (let ((map (make-sparse-keymap)))
     (set-keymap-parent map minibuffer-local-map)
     (define-key map (kbd "M-RET") #'newline)
     map)
   "Keymap of a workspace prompt, where a second line is reached.")

 (defun konix/agent-shell-workspace--read-heading-and-body (prompt &optional initial)
   "Read at PROMPT a heading and, past its first line, the body going under it.
INITIAL fills the prompt with something to edit rather than an empty line."
   (let* ((text (string-trim
                 (read-from-minibuffer
                  prompt initial konix/agent-shell-workspace-prompt-map)))
          (break (string-search "\n" text))
          (heading (string-trim (if break (substring text 0 break) text)))
          (body (if break (string-trim (substring text (1+ break))) "")))
     (when (string-empty-p heading)
       (user-error "A heading is what you are saying — there is none"))
     (list heading body)))

 (defun konix/agent-shell-workspace--body-under (body indent)
   "Return BODY as the lines under a heading, each carrying INDENT, or nothing."
   (if (or (null body) (string-empty-p (string-trim body)))
       ""
     (concat (replace-regexp-in-string "^" indent (string-trim body)) "\n")))
rule
and e takes the heading point stands in back to the prompt, its words already there to be worked on.
  • why: the user types a subject at speed and reads it back full of typos, and rewriting it by hand means making the buffer writable to do it.
  • scope: the heading’s own words: the keyword it opens on, the standing the user gave it and what is written under it are none of its business.
  • scope: and whatever heading they stand in, a question of the writer’s as readily as a subject of their own, since it is their reading it has to suit.
  • rule: and a heading left saying nothing is refused, as one typed empty is.
  • rule: and which heading it is editing is settled as the key is pressed, not as the words are typed, so it is named by its id and reached by it after.
    • why: the writer rewrites the workspace while the user types, and what stood where they were standing is another heading by the time they press return.
    • rule: and one gone from under them while they typed is refused rather than guessed at.
  • rule: and rewording the one the writer holds tells it so, in the words it now asks.
    • why: it is working on what the question said, and nothing else would tell it that what it said has changed.
    • scope: only the one it holds: any other it reads afresh when it takes it up.
    • how: it hears it as its turn ends, or at once where the user asks now for it, which is the prefix argument the answering keys take.
(defconst konix/agent-shell-workspace-reworded-said
  (concat "The user has reworded the question you hold. It asks this now:"
          " « %s ». Work on what it says now, not on what it said before.")
  "What a writer is told when the user rewords the question it holds.")

(defun konix/agent-shell-workspace-edit (&optional now)
  "Edit the words of the heading point stands in, the ones it has filled in.
NOW cuts the writer short with the news where it holds the one reworded."
  (interactive "P")
  (pcase-let* ((`(,id ,was)
                (save-excursion
                  (konix/agent-shell-workspace--goto-heading)
                  (list (org-entry-get (point) "ID")
                        (org-get-heading t t t t))))
               (said (string-trim (read-string "Heading: " was))))
    (when (string-empty-p said)
      (user-error "A heading is what it says — there is none"))
    (unless (and id (save-excursion
                      (konix/agent-shell-workspace--goto-id id)))
      (user-error "That heading is gone from under you"))
    (unless (equal said was)
      (konix/agent-shell-workspace--write
        (konix/agent-shell-workspace--goto-id id)
        (org-edit-headline said))
      (when (save-excursion
              (konix/agent-shell-workspace--goto-id id)
              (equal (konix/agent-shell-workspace--state-at-point)
                     konix/agent-shell-workspace-working-keyword))
        (message
         (if (eq t (konix/agent-shell-workspace--submit
                    (konix/agent-shell-workspace--target-writer)
                    (format konix/agent-shell-workspace-reworded-said said)
                    now))
             "Woke the writer"
           "Queued — the writer reads it as its turn ends"))))))
rule
a heading the user did not type — the words a keystroke quotes back — is refused past its first line.
  • why: a heading keeps the first line and drops the rest.
(defun konix/agent-shell-workspace--one-line-or-error (heading)
  "Refuse HEADING unless it is exactly one line."
  (unless (string-match-p "\\`[^\n]+\\'" (string-trim heading))
    (user-error "A heading is one line — select less")))

a subject of the user’s own

how
M-RET puts a subject of the user’s own at the end, in the writer’s state, with whatever the user wants to say under it and none of the bounds the writer answers to.
  • why: it is the writer that needs holding short, not the user.
rule
and it leaves the view settled, the subject it wrote folded like anything else of the writer’s.
  • why: what it writes arrives unfolded, being written rather than read.
 (defun konix/agent-shell-workspace-add-subject (subject &optional body file line quoted)
   "Put SUBJECT, and BODY under it, at the end of this workspace.
FILE and LINE, when given, come out under it as the place it is about, and
QUOTED as what was selected there."
   (interactive (konix/agent-shell-workspace--read-heading-and-body "Subject: "))
   (konix/agent-shell-workspace--write
     (save-excursion
       (goto-char (point-max))
       (unless (bolp) (insert "\n"))
       (insert "* " konix/agent-shell-workspace-fresh-keyword " " subject "\n"
               "  :PROPERTIES:\n  :ID:       " (org-id-new) "\n  :END:\n"
               (konix/agent-shell-workspace--body-under body "  ")
               (if (and quoted (not (string-empty-p quoted)))
                   (concat "  #+BEGIN_QUOTE\n"
                           (replace-regexp-in-string
                            "^" "  " (org-escape-code-in-string quoted))
                           "\n"
                           (if (and file line)
                               (format "  --- [[file+emacs:%s::%s][%s:%s]]\n"
                                       file line
                                       (file-name-nondirectory file) line)
                             "")
                           "  #+END_QUOTE\n")
                 (if (and file line)
                     (format "  - [[file+emacs:%s::%s][%s:%s]]\n"
                             file line (file-name-nondirectory file) line)
                   "")))))
   (konix/agent-shell-workspace-focus-question)
   (konix/agent-shell-workspace--submit
    (konix/agent-shell-workspace--target-writer)))
what
and the same subject can be raised from the code itself, without leaving it.
  • why: looking at a file and wanting to add a todo, the user selects a region and gets that prompt with the region already in it.
  • how: the prompt stays empty and the region comes out under the heading, quoted, the place it was selected at named inside the quote.
    • why: better not to prefill the prompt with it, but to provide the quote and the link in the body for the writer to get the context of the task.
    • therefore: the user types what they want done, and the writer reads what it was about.
    • rule: and the quote wears the shape the user’s own quotes wear: the words, then --- and the link, inside #+BEGIN_QUOTE.
      • why: their quotes look like that.
    • rule: and the region goes once it is sent.
      • why: once the region binding has sent the task, the region must be deactivated.
      • therefore: the keys the region binding lends are handed back, and the next keystroke means what it usually means.
  • rule: and it asks nothing: it finds the one in the project the user is working on, whose writer is still alive.
    • why: being asked about several workspaces the user did not care about is no help at all, and the last one worked in is a shitty assumption.
    • how: the writer’s own project is what is compared, that being what says which project a writer works in.
    • scope: finding none it says so rather than guessing, there being nothing to raise a subject with.
    • rule: and finding several writers working here, it asks which.
      • why: two writers on the same project is a choice only the user can make.
      • therefore: the asking is back, but only ever between the ones that could each be the right answer.
 (defun konix/agent-shell-workspace--project-of (buffer)
   "Return the project BUFFER sits in, by its root, or its directory."
   (when (buffer-live-p buffer)
     (with-current-buffer buffer
       (or (when-let ((project (ignore-errors (project-current nil))))
             (expand-file-name (project-root project)))
           (and default-directory (expand-file-name default-directory))))))

 (defun konix/agent-shell-workspace--open-one ()
   "Return this project's workspace whose writer lives, asking of several."
   (let* ((here (konix/agent-shell-workspace--project-of (current-buffer)))
          (candidates
           (seq-filter
            (lambda (buffer)
              (let ((writer (buffer-local-value
                             'konix/agent-shell-workspace--writer-buffer buffer)))
                (and (buffer-local-value 'konix/agent-shell-workspace-mode buffer)
                     (buffer-live-p writer)
                     (equal here
                            (konix/agent-shell-workspace--project-of writer)))))
            (buffer-list))))
     (pcase (length candidates)
       (0 (user-error "No workspace of this project has a living writer"))
       (1 (car candidates))
       (_ (get-buffer (completing-read "Raise it with which writer: "
                                       (mapcar #'buffer-name candidates)
                                       nil t))))))

 (defun konix/agent-shell-workspace-raise-here (subject &optional body file line quoted)
   "Raise SUBJECT, and BODY under it, in the open workspace, about FILE at LINE.
QUOTED is what was selected there, which comes out under the heading as well."
   (interactive
    (append (konix/agent-shell-workspace--read-heading-and-body "Subject: ")
            (list (buffer-file-name)
                  (line-number-at-pos (if (use-region-p)
                                          (region-beginning)
                                        (point)))
                  (when (use-region-p)
                    (string-trim (buffer-substring-no-properties
                                  (region-beginning) (region-end)))))))
   (with-current-buffer (konix/agent-shell-workspace--open-one)
     (konix/agent-shell-workspace-add-subject subject body file line quoted))
   (deactivate-mark))

 (with-eval-after-load 'region-bindings-mode
   (when (boundp 'konix/region-bindings-mode-map)
     (keymap-set konix/region-bindings-mode-map "w"
                 #'konix/agent-shell-workspace-raise-here)))
what
and one raised with nothing selected is about the line point is on.
  • why: a line is what the user is looking at often enough that selecting it first is a keystroke for nothing.
  • how: the line stands in for the region and is quoted the same way, so what the writer reads is what it would have read either way.
(defun konix/agent-shell-workspace-raise-this-line (subject &optional body)
  "Raise SUBJECT, and BODY under it, about the line point is on."
  (interactive (konix/agent-shell-workspace--read-heading-and-body "Subject: "))
  (konix/agent-shell-workspace-raise-here
   subject body (buffer-file-name) (line-number-at-pos)
   (string-trim (buffer-substring-no-properties
                 (line-beginning-position) (line-end-position)))))

(keymap-global-set "M-g l M-RET"
                   #'konix/agent-shell-workspace-raise-this-line)
what
and the same key raises one from the shell, watching the writer work being where the user sees what wants doing next.
  • rule: and it asks nothing either: the session names its own workspace.
    • therefore: nothing is compared and nothing is guessed, this being the one case where the workspace is already known.
  • scope: the viewport the user types in keeps the key, a second line of a message being what it means there.
(defun konix/agent-shell-workspace-raise-with-writer (subject &optional body)
  "Raise SUBJECT, and BODY under it, in the workspace bound to this session."
  (declare (modes agent-shell-mode agent-shell-viewport-view-mode))
  (interactive (konix/agent-shell-workspace--read-heading-and-body "Subject: "))
  (let* ((shell (konix/agent-shell--current-shell-or-error))
         (file (or (konix/agent-shell-workspace-file shell)
                   (user-error "No workspace is bound to that session"))))
    (with-current-buffer (konix/agent-shell-workspace--show file shell nil)
      (konix/agent-shell-workspace-add-subject subject body))))

(with-eval-after-load 'agent-shell
  (define-key agent-shell-mode-map (kbd "M-RET")
              #'konix/agent-shell-workspace-raise-with-writer)
  (define-key agent-shell-viewport-view-mode-map (kbd "M-RET")
              #'konix/agent-shell-workspace-raise-with-writer))

answering

what
yes and no are one keystroke each.
what
anything else is a line the user types and what goes under it, and it travels with whatever the user was looking at.
rule
answering one hands it back, whatever state it stood in, a settled one included.
  • why: answering a settled item and having it stay settled is a bug.
  • therefore: the user needs no second keystroke to reopen something they have just said one more thing about.

told there is something there

rule
a writer the limit has stopped is not told at all.
  • why: it has reached its limit, so there is no need to nudge it.
  • how: the last words a session said are what say so, which is how the session tools already tell one the limit stopped from one that finished.
  • therefore: the user hears it plainly rather than watching a nudge go into a session that cannot answer.
rule
a writer at work hears it as its turn ends, whatever ended the turn.
  • why: whether it hears it at all cannot turn on what it happened to be doing when the user pressed the key.
  • rule: and it is left to finish, the telling waiting rather than cutting in.
    • why: the rest of its turn is work the user asked for and would be thrown away.
  • scope: unless the user asks for it now, which cuts a working writer short too, for when what the user has to say makes the rest of its turn pointless.
how
now is the prefix argument on the keys that answer a question.
rule
and a turn the user cuts short takes whatever was waiting on its end with it.
  • why: cutting one is how they stop a writer, and words landing the moment it stops start it again.
  • scope: unless the cut was made to make way for those very words, which is what now does.
rule
and telling one says back whether it heard it or will hear it at the turn’s end.
  • why: the user pressed a key and has to know whether the writer is on it already.

what the writer is told

rule
what it is told carries the headings themselves, not an invitation to go and read them.
  • why: show the stuff it would list, to save the call to the tool.
  • how: the listing is one function, called by the tool and by this, so the writer reads the same thing either way.
  • rule: and the goal rides on it, ahead of the headings.
    • why: the user changes the goal and the writer would never see it.
    • therefore: W stops being the only way the writer hears a goal it was not bound with, and a goal changed mid-session reaches it at the next word.
    • scope: a workspace naming none says nothing, so the reminder appears only where there is one.
    • rule: and it rides on everything else the writer is told without asking: the listing it reads back, and each thing it is steered with.
      • why: anywhere it may matter to remind the writer that there is a goal to focus on.
      • how: one line built from the file, put in front of whatever was going to be said, so nothing else has to know about goals.
  • rule: and it says that a place is named by a link and never by directions.
    • why: the writer answers « where is it » with a path in prose, and the user will never follow that.
    • therefore: whatever the writer names, one keystroke opens.
  • rule: and it says that the one taken up is the only thing worked on.
    • why: the writer wanders off the question into whatever it notices on the way, and comes back with an answer to something else.
    • therefore: what it notices has one place to go, which is a question of its own, and the user decides whether it is worth doing.
  • rule: and where nothing is the writer’s, it says so and asks nothing of it.
    • why: a writer facing an empty workspace does not know it needs to write a question, so the nudge has to say it.
    • what: nothing is there to do, and no question of the writer’s own invention belongs in the workspace.
  • rule: and it carries only what the writer can act on.
    • why: it should only show the stuff that is actionable.
    • what: the ones waiting on the writer, with their bodies. A settled one, one waiting on the user, one they put off and every fact drop out.
    • scope: the tool itself still gives the whole file back, the writer sometimes needing to see where a question of its own stands.
rule
and telling the writer anything puts the steering on again first, so it says what the note now says.
  • why: a reload never reaches what was copied onto the session at binding.
  • rule: and its failing cannot stop the telling.
    • why: the writer was not woken at all, the putting-on having failed first.

(defun konix/agent-shell-workspace--goal-in (file)
  "Return the goal the workspace FILE declares, or nil."
  (when (file-readable-p file)
    (konix/agent-shell-workspace--read-file file
      (let ((goal (konix/agent-shell-workspace--keyword "GOAL")))
        (unless (or (null goal) (string-empty-p (string-trim goal)))
          (string-trim goal))))))

(defun konix/agent-shell-workspace--goal-line (file)
  "Return the workspace FILE's goal as a line to put ahead of something, or empty."
  (if-let ((goal (konix/agent-shell-workspace--goal-in file)))
      (concat (format konix/agent-shell-workspace-goal-said goal) "\n\n")
    ""))

(defconst konix/agent-shell-workspace-pressing-said
  (concat "\n\nThe user has marked « %s » [#%s], the highest standing there:"
          " take that one up before any other.")
  "What a writer is told of the priority the user put on one of its questions.")

(defun konix/agent-shell-workspace--the-pressing-one (file)
  "Return what FILE says of the priority its questions carry, or nothing."
  (konix/agent-shell-workspace--read-file file
    (let (best)
      (goto-char (point-min))
      (while (konix/agent-shell-workspace--search-state
              konix/agent-shell-workspace-writers-regexp)
        (when-let* ((priority (org-element-property
                               :priority (org-element-at-point)))
                    (higher (or (null best) (< priority (car best)))))
          (setq best (cons priority (org-get-heading t t t t)))))
      (if best
          (format konix/agent-shell-workspace-pressing-said
                  (string-trim (cdr best)) (char-to-string (car best)))
        ""))))

(defun konix/agent-shell-workspace--nudge (writer)
  "Return what WRITER is told, the goal and the workspace's headings with it."
  (if-let ((file (konix/agent-shell-workspace-file writer))
           (readable (file-readable-p file)))
      (let ((left (konix/agent-shell-workspace--listing file t)))
        (concat (konix/agent-shell-workspace--goal-line file)
                (if left
                    (concat "There is something for you in the workspace:\n\n"
                            left
                            "\n\nTake one up and act on it, and on nothing else."
                           " Whatever else you notice on the way is a question to"
                           " write, never work to do."
                           (konix/agent-shell-workspace--the-pressing-one file))
                  (concat "Nothing in the workspace is yours, so there is nothing to"
                          " do here. Do not invent a question to have something to"
                          " do."))))
    "There is something for you in the workspace. Read it back and take it up."))

(defvar-local konix/agent-shell-workspace--briefed nil
  "Non-nil once this writer has been told what a workspace of its is.")

(defun konix/agent-shell-workspace--briefing-owed (writer text)
  "Return TEXT with the briefing ahead of it, WRITER never having had one."
  (if konix/agent-shell-workspace--briefed
      text
    (setq-local konix/agent-shell-workspace--briefed t)
    (let ((file (konix/agent-shell-workspace-file writer)))
      (concat (konix/agent-shell-workspace-binding-briefing
               file (konix/agent-shell-workspace--goal-in file))
              "\n\n" text))))

(defun konix/agent-shell-workspace--say-now (writer text)
  "Say TEXT to WRITER, whose turn is over."
  (agent-shell--insert-to-shell-buffer
   :shell-buffer writer :text text :submit t :no-focus t)
  t)

(defun konix/agent-shell-workspace--submit (writer &optional text now)
  "Tell WRITER of the workspace, as its turn ends or at once if NOW.
TEXT overrides the headings it would otherwise be handed.  Returns t
where it heard it and `queued' where it hears it as the turn ends."
  (ignore-errors (konix/agent-shell-workspace--install-steering writer))
  (when (with-current-buffer writer
          (ignore-errors (konix/agent-shell--rate-limited-p)))
    (user-error "%s has reached its limit, so it is left alone"
                (buffer-name writer)))
  (with-current-buffer writer
    (let ((text (konix/agent-shell-workspace--briefing-owed
                 writer (or text (konix/agent-shell-workspace--nudge writer))))
          (cut-for-it (and now (shell-maker-busy))))
      (when cut-for-it
        (let ((agent-shell-confirm-interrupt nil))
          (ignore-errors (agent-shell-interrupt))))
      (if (not (shell-maker-busy))
          (konix/agent-shell-workspace--say-now writer text)
        (let ((waiting (konix/agent-shell-workspace--say-when-idle
                        writer text cut-for-it)))
          (if (shell-maker-busy)
              'queued
            (agent-shell-unsubscribe :subscription waiting)
            (konix/agent-shell-workspace--say-now writer text)))))))
how
a buffer mid-turn refuses the text, so it waits on turn-complete, and what it waits for is asked for before the turn is looked at again.
  • why: a turn ending between the two would leave nobody listening and the words would be dropped.
(defun konix/agent-shell-workspace--say-when-idle (writer text &optional cut-for-it)
  "Wait for WRITER's turn to end and say TEXT then, returning what waits.
A turn cut short takes TEXT with it, CUT-FOR-IT saying the cut was made to
make way for it."
  (let (token)
    (setq token
          (agent-shell-subscribe-to
           :shell-buffer writer :event 'turn-complete
           :on-event
           (lambda (event)
             (agent-shell-unsubscribe :subscription token)
             (when (and (buffer-live-p writer)
                        (or cut-for-it
                            (not (equal (map-elt (map-elt event :data) :stop-reason)
                                        "cancelled"))))
               (konix/agent-shell-workspace--say-now writer text)))))))

what the user says, put down

rule
what the user says waits under the question, and two things said in a row both wait there.
  • rule: it opens on no keyword.
  • rule: and answering or closing while point stands on one reaches the question it sits under, not the answer.
rule
what the words are about is settled as the key is pressed, not as they are typed.
  • why: the writer rewrites the workspace under the user while they type, and what stood under point is not there to be read afterwards.
  • what: the fact they were on, the question it sits under and the line itself travel with the words.
(defun konix/agent-shell-workspace--line-here ()
  "Return the words of the line point is on, quoted, the markup dropped."
  (let ((words (if (org-at-heading-p)
                   (org-get-heading t t t t)
                 (org-link-display-format
                  (string-trim
                   (buffer-substring-no-properties (line-beginning-position)
                                                   (line-end-position)))))))
    (when (string-prefix-p "- " words)
      (setq words (substring words 2)))
    (when-let* ((said (string-search " :: " words)))
      (setq words (substring words (+ said 4))))
    (format "« %s »" (string-trim words))))

(defun konix/agent-shell-workspace--stood-on ()
  "Return what the user stands on: a fact, the question's id, and the line's words."
  (list (konix/agent-shell-workspace--fact-here)
        (unless (org-before-first-heading-p)
          (save-excursion
            (konix/agent-shell-workspace--goto-question)
            (org-entry-get (point) "ID")))
        (konix/agent-shell-workspace--line-here)))

(defun konix/agent-shell-workspace--standing-on-a-question ()
  "Return what the user stands on, coming back from a diff to the question first."
  (when (bound-and-true-p konix/agent-shell-workspace-diff-mode)
    (unless (buffer-live-p konix/agent-shell-workspace--buffer)
      (user-error "The workspace this diff was shown from is gone"))
    (konix/agent-shell-workspace-back))
  (konix/agent-shell-workspace--stood-on))

(defun konix/agent-shell-workspace--write-answer (standing answer &optional body)
  "Put ANSWER, and BODY under it, at the question STANDING was taken on."
  (pcase-let ((`(,_fact ,question ,here) standing))
    (konix/agent-shell-workspace--write
      (save-excursion
        (unless (and question (konix/agent-shell-workspace--goto-id question))
          (konix/agent-shell-workspace--goto-heading))
        (goto-char (konix/agent-shell-workspace--question-end))
        (unless (bolp) (insert "\n"))
        (insert "** " konix/agent-shell-workspace-user-said
                (string-trim answer) "\n"
                "   :PROPERTIES:\n   :ID:       " (org-id-new) "\n   :END:\n"
                "   - what :: said on " here "\n"
                (konix/agent-shell-workspace--body-under body "   "))))))
how
the question then turns the writer’s, wherever it stood.
  • rule: and the view is settled once the answer is written, never patched as it goes.
  • how: and the echo area says whether the writer was woken or is still at work.
(defun konix/agent-shell-workspace--send (standing answer &optional now body)
  "Put ANSWER, and BODY under it, where STANDING says, and tell the writer, NOW."
  (konix/agent-shell-workspace--one-line-or-error answer)
  (konix/agent-shell-workspace--target-writer)
  (if (car standing)
      (konix/agent-shell-workspace--ask-about-fact standing answer body)
    (konix/agent-shell-workspace--write-answer standing answer body)
    (when-let* ((question (nth 1 standing)))
      (konix/agent-shell-workspace--goto-id question))
    (konix/agent-shell-workspace--hand-back)
    (message
     (if (eq t (konix/agent-shell-workspace--submit
                konix/agent-shell-workspace--writer-buffer nil now))
         "Woke the writer"
       "Queued — the writer reads it as its turn ends")))
  (konix/agent-shell-workspace--goto-waiting)
  (konix/agent-shell-workspace-focus-question))
how
three keys sit on it: one for what the user types, and one each for yes and no.
(defun konix/agent-shell-workspace-answer (&optional now)
  "Read an answer to what point stands on and send it, NOW if asked to."
  (interactive "P")
  (let ((standing (konix/agent-shell-workspace--standing-on-a-question))
        (place (konix/agent-shell-workspace--location-at-point t)))
    (pcase-let ((`(,answer ,body)
                 (konix/agent-shell-workspace--read-heading-and-body
                  (if place
                      (format "Answer on %s:%d: "
                              (file-name-nondirectory (car place)) (cdr place))
                    "Answer: "))))
      (konix/agent-shell-workspace--send standing answer now body))))

(defun konix/agent-shell-workspace-answer-yes (&optional now)
  "Answer yes to what point stands on, NOW if asked to."
  (interactive "P")
  (konix/agent-shell-workspace--send
   (konix/agent-shell-workspace--standing-on-a-question) "yes" now))

(defun konix/agent-shell-workspace-answer-no (&optional now)
  "Answer no to what point stands on, NOW if asked to."
  (interactive "P")
  (konix/agent-shell-workspace--send
   (konix/agent-shell-workspace--standing-on-a-question) "no" now))
what
the answer the user gives most often after those is a word of the question quoted back, asking what it was supposed to mean.
  • scope: with nothing selected it asks that of the whole question.
(defun konix/agent-shell-workspace-answer-what (&optional now)
  "Ask the writer what it was supposed to mean.
Sends back the region, or the whole question when nothing is selected.  NOW if asked to."
  (interactive "P")
  (konix/agent-shell-workspace--send
   (konix/agent-shell-workspace--standing-on-a-question)
   (if (use-region-p)
       (format "\"%s\" ?"
               (string-trim (buffer-substring-no-properties
                             (region-beginning) (region-end))))
     "?")
   now))
therefore
answering is what hands the question over, so the state follows the answer rather than waiting for the user to also declare it, and so does moving on.

on a fact, a subject instead

rule
on a fact the same keys raise a subject about it instead.
  • why: the writer says a lot of stupid shit as a fact and the user wants to ask about them.
  • what: what they say becomes a subject of their own, in the writer’s state, its body linking the fact it is about.
  • therefore: the fact stands untouched, the writer’s word kept as it wrote it, and what the user thinks of it is a heading of its own.
  • scope: it makes the fact reached, so a fact worth arguing with stops counting as work left by being argued with.
  • rule: and the line pointing at a fact counts as standing on that fact.
    • why: that link is where the user reads a fact from, and answering on it answered the question the link sits under.
  • rule: and the line they stood on goes under the subject, the way it does under an answer.
    • why: what they are arguing with is one bullet of the fact, and the subject read as being about the whole of it.
    • rule: and it goes in as a quotation of its words, inside the bullet.
      • why: written out as it stood, the markup of the line came with it and the bullet read as rubbish.
      • how: the stars of a heading, the dash of a bullet and the intention before its text are all dropped, and what is left is quoted.
(defun konix/agent-shell-workspace--fact-pointed-at ()
  "Return (ID . HEADING) for the fact the line point is on points at, or nil."
  (save-excursion
    (beginning-of-line)
    (when (re-search-forward org-link-any-re (line-end-position) t)
      (goto-char (match-beginning 0))
      (let ((link (org-element-context)))
        (when (and (eq (org-element-type link) 'link)
                   (equal (org-element-property :type link) "id"))
          (let ((id (org-element-property :path link)))
            (when (and (konix/agent-shell-workspace--goto-id id)
                       (konix/agent-shell-workspace--fact-at-point-p))
              (cons id (org-get-heading t t t t)))))))))

(defun konix/agent-shell-workspace--fact-here ()
  "Return (ID . HEADING) for the fact point stands in, or the one it points at."
  (or (konix/agent-shell-workspace--fact-pointed-at)
      (save-excursion
        (konix/agent-shell-workspace--goto-heading)
        (when (konix/agent-shell-workspace--fact-at-point-p)
          (cons (org-entry-get (point) "ID") (org-get-heading t t t t))))))

(defun konix/agent-shell-workspace--ask-about-fact (standing answer &optional body)
  "Raise ANSWER, and BODY under it, as a subject about the fact STANDING names."
  (pcase-let* ((`(,fact ,_question ,here) standing)
               (id (car fact))
               (heading (cdr fact)))
    (unless id
      (user-error "That fact carries no id, so nothing can point at it"))
    (konix/agent-shell-workspace-add-subject
     answer
     (concat (if (and body (not (string-empty-p (string-trim body))))
                 (concat (string-trim body) "\n")
               "")
             (format "- what :: said on %s\n" here)
             (format "- what :: about [[id:%s][%s]]" id heading)))))

finding the writer again

what
the writer that asked is a live buffer, and the workspace holds on to it for as long as that buffer lives.
(defvar-local konix/agent-shell-workspace--writer-buffer nil
  "Agent-shell buffer that published this workspace, where \\`r' sends questions.")

(put 'konix/agent-shell-workspace--writer-buffer 'permanent-local t)
how
four steps: the buffer already known, the session the workspace itself names, resuming that same session where it is named but not live, and failing all three the user’s choice — and each is remembered.
  • why: the front matter names the session writing there, so asking the user which one is asking them what the file has said all along.
  • scope: it holds while that session is open; one whose writer is gone asks as ever.
rule
a subject reaching an unstarted writer this way starts it — nothing else does, the tools a writer answers with never spawning one.
  • why: the workspace calling this on its own account is the one place a session with nobody at it yet still has a reason to exist.
  • how: it resumes the session named, in its own project, unfocused — never taking the window from whatever the user was doing.
  • rule: and its viewport is made ready before anything is said to it.
    • why: a shell started unfocused mirrors nothing into a viewport that does not exist yet, so whatever is said first would land in the shell buffer alone.
  • rule: and the workspace that resumed it is bound to it there and then.
    • why: the store gives a session its workspace back only once it says its id, and whatever resumed it has something for it before that.
    • therefore: seeing to a writer resumes it and sees to it in the one gesture, where before it stopped on a session bound to nothing yet.
(defun konix/agent-shell-workspace--writer-named ()
  "Return the live session this workspace's front matter names, or nil."
  (when-let* ((named (konix/agent-shell-workspace--keyword "SESSION")))
    (seq-find (lambda (shell)
                (when-let* ((spec (konix/org-agent-shell--session-spec shell)))
                  (string-search spec named)))
              (agent-shell-buffers))))

(defun konix/agent-shell-workspace--named-session-spec ()
  "Return the raw agent-shell spec this workspace's SESSION keyword names, or nil."
  (when-let* ((named (konix/agent-shell-workspace--keyword "SESSION"))
              (start (string-search "agent-shell:" named))
              (from (+ start (length "agent-shell:"))))
    (substring named from (string-search "]" named from))))

(defun konix/agent-shell-workspace--resume-named-writer ()
  "Resume this workspace's named session in the background, or nil without one."
  (when-let* ((spec (konix/agent-shell-workspace--named-session-spec)))
    (pcase-let* ((`(,session-id ,rest) (split-string spec "\\?cwd="))
                 (`(,cwd ,_line) (split-string (or rest "") "&line="))
                 (shell (konix/org-agent-shell--resume-session session-id cwd)))
      (konix/agent-shell-ensure-viewport shell)
      (when buffer-file-name
        (konix/agent-shell-workspace--remember shell buffer-file-name))
      shell)))

(defun konix/agent-shell-workspace--target-writer ()
  "Return the writer this workspace talks to, asking only if it has to."
  (or (and (buffer-live-p konix/agent-shell-workspace--writer-buffer)
           konix/agent-shell-workspace--writer-buffer)
      (setq-local konix/agent-shell-workspace--writer-buffer
                  (konix/agent-shell-workspace--writer-named))
      (setq-local konix/agent-shell-workspace--writer-buffer
                  (konix/agent-shell-workspace--resume-named-writer))
      (let ((shells (seq-filter
                     (lambda (buffer)
                       (with-current-buffer buffer
                         (derived-mode-p 'agent-shell-mode)))
                     (buffer-list))))
        (unless shells
          (user-error "No agent-shell buffer to send this to"))
        (setq-local konix/agent-shell-workspace--writer-buffer
                    (get-buffer
                     (completing-read "Send this workspace's answers to: "
                                      (mapcar #'buffer-name shells)
                                      nil t))))))
rule
it opens where a writer opens when the user picks one out of the tree, so the workspace stays where the user left it.
(defun konix/agent-shell-workspace-goto-writer ()
  "Go to the writer this workspace talks to, leaving its mode alone."
  (interactive)
  (let ((writer (konix/agent-shell-workspace--target-writer)))
    (konix/mcp-server--pop-to-agent-from-tree
     (or (and (fboundp 'agent-shell-viewport--buffer)
              (agent-shell-viewport--buffer
               :shell-buffer writer :existing-only t))
         writer))))

the user away from the keyboard

what
the user leaves for the evening, and a writer that needs them stops there and waits the night out.
rule
so the user says they are away, and it stays said until they say they are back.
  • why: it is a state they are in, not a thing done on the way out: whoever gets stuck an hour later is stuck all the same.
  • rule: and it is one switch for the whole of Emacs, not one for each workspace.
    • why: they walk away from the keyboard, not from one piece of work, and every writer of theirs is left to itself at once.
(defconst konix/agent-shell-workspace-afk-said
  (concat "You asked for something I cannot allow while the user is away."
          " Do not work around it: refine what you hold, saying what you"
          " need, and take up another.")
  "What a writer is told when it asks the user for something they are away from.")

(defun konix/agent-shell-workspace--still-its-own-p (writer)
  "Non-nil when WRITER's workspace leaves it something of its own to do."
  (when-let* ((file (konix/agent-shell-workspace-file writer)))
    (not (konix/agent-shell-workspace--writer-done-p file))))

(defun konix/agent-shell-workspace--send-away (writer)
  "Cut WRITER short and tell it the user is away from what it asked."
  (konix/agent-shell-workspace--submit
   writer konix/agent-shell-workspace-afk-said t))
rule
and saying it sends off whoever is already stuck, not only whoever gets stuck after.
  • why: the user leaves because the thing has been sitting there, so the one already waiting is the one they meant.
  • how: what such a writer waits on is cancelled first, nothing having answered it.
(defun konix/agent-shell-workspace--writers ()
  "Return every live agent-shell with a workspace bound to it."
  (seq-filter (lambda (buffer)
                (with-current-buffer buffer
                  (and (derived-mode-p 'agent-shell-mode)
                       (konix/agent-shell-workspace-file buffer))))
              (buffer-list)))

(defun konix/agent-shell-workspace--send-the-stuck-away ()
  "Send off every writer already waiting on the user with work of its own left."
  (dolist (writer (konix/agent-shell-workspace--writers))
    (when (and (with-current-buffer writer
                 (konix/agent-shell--pending-permission-ids))
               (konix/agent-shell-workspace--still-its-own-p writer))
      (with-current-buffer writer
        (konix/agent-shell--cancel-pending-permissions))
      (konix/agent-shell-workspace--send-away writer))))

(define-minor-mode konix/agent-shell-workspace-afk-mode
  "Send off whichever writer needs the user, the user being away from them."
  :global t
  :lighter " AFK"
  :group 'agent-shell
  (when konix/agent-shell-workspace-afk-mode
    (konix/agent-shell-workspace--send-the-stuck-away)))
rule
and while it is said, a writer asking for what only the user could allow is refused it, cut short, and told to refine what it holds and take up another.
  • why: it asked to be let on with work nobody will let it on with, and the answer it wants is the user’s, who is not there to give it.
  • therefore: what it holds comes back as a question, which is what the user reads when they sit down again.
  • rule: and it is answered after the rules that allow and refuse of their own, and before what calls the user over.
    • why: what a rule already allows is allowed with the user away as with them there, and being called over is the one thing being away means not happening.
  • scope: only a writer with something of its own still to do; one with nothing left is the cut turn’s own business, which takes it whatever the user is doing.
  • scope: and only a writer with a workspace, there being nowhere else to send one.
(defun konix/agent-shell-workspace--afk-responder (permission)
  "Refuse PERMISSION for the user, away, and send its writer off what it holds."
  (when (and konix/agent-shell-workspace-afk-mode
             (konix/agent-shell-workspace--still-its-own-p (current-buffer)))
    (when-let* ((refusal (seq-find
                          (lambda (option)
                            (equal (map-elt option :kind) "reject_once"))
                          (map-elt permission :options))))
      (funcall (map-elt permission :respond) (map-elt refusal :option-id))
      (konix/agent-shell-workspace--send-away (current-buffer))
      t)))

(add-hook 'konix/agent-shell-permission-responder-functions
          #'konix/agent-shell-workspace--afk-responder 50)

addressing a heading

what
how far a heading runs is one question — where the next heading shallow enough to end it stands — asked of two depths.
  • what: a question or a fact runs on to the next of its own depth, taking the answers under it with it, and an answer runs only as far as whatever heading comes next.
  • why: the two were two searches, and the one that read too far took a question’s answers for its own.
  • therefore: the depth is said out loud at each of the two, and a third reading would say its own rather than write another search.
(defun konix/agent-shell-workspace--ends-at (depth)
  "Return where what point stands in ends: the next heading no deeper than DEPTH."
  (save-excursion
    (unless (org-before-first-heading-p)
      (org-back-to-heading t))
    (let (found)
      (while (and (not found) (outline-next-heading))
        (when (<= (org-current-level) depth)
          (setq found (point))))
      (or found (point-max)))))

(defun konix/agent-shell-workspace--question-end ()
  "Return where the question or fact containing point ends."
  (konix/agent-shell-workspace--ends-at 1))

(defun konix/agent-shell-workspace--answer-end ()
  "Return where the answer containing point ends."
  (konix/agent-shell-workspace--ends-at 2))
what
a call of the writer’s reaches a heading by its id, and by nothing else.
  • rule: a call naming no id reaches no heading at all, and writes a new one.
    • why: the id is the best way to reach a heading.
  • rule: and whose id it is, is org’s own reading of its property, not ours.
    • why: the writer must not delete what is not done, and a rewrite reaches everything under the heading its id lands on.
    • therefore: an id names one heading and never the one above it, whatever the depths around it, because a property belongs to the heading it is drawn under.
(defun konix/agent-shell-workspace--goto-id (id)
  "Move to the question, fact or answer whose id is ID, nil when there is none."
  (when-let ((id id)
             (heading (org-find-property "ID" id)))
    (goto-char heading)
    t))
what
whichever line of a heading the user stands on, the keystroke acts on that heading.
  • what: and standing on an answer, the heading it acts on is the question that answer sits under.
(defun konix/agent-shell-workspace--goto-heading ()
  "Move to the heading point stands in, or to the first one when point is above them."
  (when (org-before-first-heading-p)
    (org-next-visible-heading 1))
  (org-back-to-heading t))

(defun konix/agent-shell-workspace--goto-question ()
  "Move to the question point stands in, from an answer under it or from itself."
  (konix/agent-shell-workspace--goto-heading)
  (when (equal (org-current-level) 2)
    (org-up-heading-safe)))
what
and what tells a fact from a question is the one thing a fact has not: a state to stand in.
(defun konix/agent-shell-workspace--state-at-point ()
  "Return the keyword the question at point opens on, nil when it is no question."
  (and (equal (org-current-level) 1)
       (org-get-todo-state)))

(defun konix/agent-shell-workspace--fact-at-point-p ()
  "Non-nil when the heading point is on is a fact rather than a question."
  (and (equal (org-current-level) 1)
       (not (konix/agent-shell-workspace--state-at-point))))
what
and within it, the place the user is standing on is the one the keystroke acts on.
(defconst konix/agent-shell-workspace-link-regexp
  "\\[\\[file\\(?:\\+emacs\\)?:\\([^]]+?\\)::\\([0-9]+\\)\\]"
  "Regexp matching a location link of the workspace.")

(defconst konix/agent-shell-workspace-pointing-regexp
  (concat "^ *- \\(?:.* : \\)?\\(?:"
          konix/agent-shell-workspace-link-regexp
          "\\|\\[\\[[a-z][a-z0-9+.-]*:[^]]+\\]\\]\\)")
  "Regexp matching a line saying only where to look: a place, or an address.")

(defun konix/agent-shell-workspace--link-on-line ()
  "Return (FILE . LINE) for a location link on the current line, or nil."
  (save-excursion
    (beginning-of-line)
    (when (re-search-forward konix/agent-shell-workspace-link-regexp
                             (line-end-position) t)
      (cons (match-string 1) (string-to-number (match-string 2))))))
how
the link on the line point is on wins, and failing that the heading’s first one.
(defun konix/agent-shell-workspace--location-at-point (&optional noerror)
  "Return (FILE . LINE) for the place point is on.
NOERROR returns nil where the heading names no place at all."
  (or (konix/agent-shell-workspace--link-on-line)
      (save-excursion
        (konix/agent-shell-workspace--goto-heading)
        (let ((limit (save-excursion (org-end-of-subtree t t))))
          (if (re-search-forward konix/agent-shell-workspace-link-regexp
                                 limit t)
              (cons (match-string 1) (string-to-number (match-string 2)))
            (unless noerror
              (user-error "This heading carries no location")))))))

what the writer can do to the file

rewriting it under the user

rule
the file is written on disk and the user’s buffer picks the change up itself.
rule
the user’s point is never moved by a rewrite.
rule
and a rewrite folds what it wrote, leaving open only what waits on the user.
  • why: what a rewrite writes arrives unfolded, and the user was left reading the writer’s answers and facts.
  • how: the folding rules alone, not the walk, so nothing opens and point stays.
rule
and a rewrite does not put itself in front of the user — it joins the tracking round the writers are already on.
rule
it joins that round only while a question is the user’s to answer, and leaves it otherwise.
rule
a rewrite is another of the moments the steering is put on again, so a refusal firing between two tellings still names the questions as they stand.
(defvar-local konix/agent-shell-workspace--nudged 'none
  "What waited on the user when this workspace was last put in the round.")

(put 'konix/agent-shell-workspace--nudged 'permanent-local t)

(defun konix/agent-shell-workspace--asking-here ()
  "Return the ids of the questions asking the user something in this buffer."
  (let (found)
    (save-excursion
      (goto-char (point-min))
      (unless (org-at-heading-p)
        (outline-next-heading))
      (while (org-at-heading-p)
        (when (equal (konix/agent-shell-workspace--state-at-point)
                     konix/agent-shell-workspace-refine-keyword)
          (push (org-entry-get nil "ID") found))
        (outline-next-heading)))
    (nreverse found)))

(defun konix/agent-shell-workspace--tracked-or-not (buffer file)
  "Put BUFFER, holding the workspace FILE, in the round or take it out."
  (let* ((asking (konix/agent-shell-workspace--asking-here))
         (done (konix/agent-shell-workspace--writer-done-p file))
         (now (cons done asking)))
    (if (or asking done)
        (unless (equal now konix/agent-shell-workspace--nudged)
          (tracking-add-buffer buffer))
      (tracking-remove-buffer buffer))
    (setq-local konix/agent-shell-workspace--nudged now)))

(defun konix/agent-shell-workspace--show (file writer fresh)
  "Prepare the workspace FILE, telling it the WRITER that asked.
Point and folding are placed only when FRESH."
  (let* ((existing (find-buffer-visiting file))
         (buffer (or existing (find-file-noselect file))))
    (konix/agent-shell-workspace--settling-now buffer)
    (with-current-buffer buffer
      (unless (bound-and-true-p auto-revert-mode)
        (auto-revert-mode 1))
      (setq-local konix/agent-shell-workspace--writer-buffer writer)
      (konix/agent-shell-workspace-mode 1)
      (when (and existing (not (buffer-modified-p)))
        (revert-buffer t t t))
      (if fresh
          (progn
            (goto-char (point-min))
            (org-next-visible-heading 1)
            (konix/agent-shell-workspace-focus-question))
        (konix/agent-shell-workspace-focus-question))
      (konix/agent-shell-workspace--tracked-or-not buffer file))
    (when (buffer-live-p writer)
      (konix/agent-shell-workspace--steer writer)
      (konix/agent-shell-workspace--cut-short writer))
    buffer))

writing one question

rule
a write reaches one question and leaves the rest untouched.
  • scope: that holds of every heading these tools wrote; one the user made by hand carries no id, and the write gives it one.
  • scope: and a line of the user’s starting on a star, pasted into a body, reads as a heading there, so it takes a drawer of its own while another question is written.
rule
a rejected write leaves the file untouched, the write happening only once the whole question has been accepted.
rule
and a write of the writer’s own cuts its turn short as readily as anything else, the moment it leaves the writer nothing.
  • why: a writer that finishes its own work writes prose before it stops, and being cut where it stands is worth more than being read.
(defun konix/agent-shell-workspace--edit (mutate)
  "Rewrite the workspace by calling MUTATE in a buffer holding it.
Returns its path."
  (let* ((writer (konix/agent-shell-workspace--writer))
         (file (konix/agent-shell-workspace--file-or-error)))
    (unless (file-readable-p file)
      (error "No workspace in %s — bind one first" file))
    (konix/agent-shell-workspace--write-file file
      (konix/agent-shell-workspace--ensure-well-formed)
      (konix/agent-shell-workspace--ensure-session-link writer)
      (funcall mutate))
    (konix/agent-shell-workspace--show file writer nil)
    file))

one tool, adding or replacing

what
writing a question adds it or replaces it, in one operation.
  • why: Occam’s razor — two tools would be one more than the work needs.
rule
and one coming back to the user carries a body, or the call is refused before anything is written.
  • scope: naming no act is one coming back to the user, so the commonest call is the one held to it.
  • scope: a rewrite carrying no body empties the question it replaces, so it is held to it too.
rule
a question the writer has just made comes back to the user, and no other act on a new one is allowed.
  • why: otherwise the user did not have a say about what was to be done.
  • scope: it reaches only the making of one — a rewrite takes any act, the question having been put to them already.
(defconst konix/agent-shell-workspace-new-question-error
  (concat "A question you have just made comes back to the user, so ask it and wait."
          " Take one up only once they have had their say on it.")
  "What a writer is told when it makes a question and keeps it.")

(defun konix/agent-shell-workspace--users-act-p (act)
  "Non-nil when ACT hands a question to the user, naming none doing so too."
  (or (null act)
      (string-empty-p (string-trim act))
      (and (member (cdr (assoc-string (string-trim act)
                                      konix/agent-shell-workspace-acts t))
                   konix/agent-shell-workspace-users-keywords)
           t)))
    (defun konix/mcp-server-set-workspace-question
        (file line &optional label note says also act id keyword url revspec)
      "Write one question of the workspace at FILE and LINE, replacing or adding it.

REVSPEC is the change it is about, kept on it and read against; without ACT the
question comes back to the user.  KEYWORD is the name ACT went by before, taken
while a session that opened on the old schema is still running.

MCP Parameters:
  file - Absolute path of the question's anchor
  line - Line in that file
  label - What this question asks the user
  note - Optional JSON array of « intention :: text » bullets
  says - Optional caption for the link itself
  also - Optional JSON array of further {file, line, says} or {url, says}
  act - Optional work, refine, put-down or close-for-sure; settling is the user's own
  id - Optional id of the question to rewrite, as the listing tool gives it
  keyword - What act was called before; pass act instead
  url - Optional web address, written out whole and counting against no limit
  revspec - Optional revision this question is about, as git would take it"
how
the id is what decides, so the same call rewrites the question it names and adds one when it names none.
(mcp-server-lib-with-error-handling
 (setq act (or act keyword))
 (when (and (null id)
            (not (konix/agent-shell-workspace--users-act-p act)))
   (error "%s" konix/agent-shell-workspace-new-question-error))
 (when (and (konix/agent-shell-workspace--users-act-p act)
            (null (konix/mcp-server-decode-json-list note)))
   (error "%s" konix/agent-shell-workspace-empty-handover-error))
 (let* ((line (if (stringp line) (string-to-number line) line))
        (keyword (and act (not (string-empty-p (string-trim act)))
                      (konix/agent-shell-workspace--act-keyword act id)))
        (entry (list (cons 'file file) (cons 'line line)
                     (cons 'label label) (cons 'note note)
                     (cons 'says says) (cons 'also also)
                     (cons 'url url) (cons 'revspec revspec)
                     (cons 'keyword keyword) (cons 'id id)))
        added kept-answers kept-links kept-said kept-heading carries revised)
   (konix/agent-shell-workspace--edit
    (lambda ()
      (let* ((revisions (konix/agent-shell-workspace--revisions))
             (revspec (setq revised
                            (konix/agent-shell-workspace--revision-of
                             entry id revisions)))
             (root (or (konix/agent-shell-workspace--keyword "DIRECTORY")
                       (file-name-directory file)))
             (diff (when revspec
                     (konix/agent-shell-workspace--render-diff
                      revspec
                      (delete-dups
                       (delq nil (mapcar
                                  #'car
                                  (konix/agent-shell-workspace--places entry))))
                      root
                      konix/agent-shell-workspace-working-diff-buffer)))
             (question (konix/agent-shell-workspace--render-question
                    entry diff (konix/agent-shell-workspace--states)
                    (konix/agent-shell-workspace--priorities)
                    revisions)))
        (setq carries
              (and diff
                   (seq-some
                    (lambda (place)
                      (with-current-buffer diff
                        (konix/agent-shell-workspace--hunk-text
                         (car place) (cadr place))))
                    (konix/agent-shell-workspace--places entry))))

rewriting one that already stands

rule
where the question already stands, a closed one is refused and any other has the writer’s own words replaced.
  • rule: and what the user put there outlives the rewrite: their answers, and the links to the facts reporting on it.
    • why: a rewrite of the heading threw their answers away with it, and left the facts it reported through reaching nothing.
    • how: both are lifted out before the question goes and put back after it, the links through the same call that wrote them in the first place.
  • rule: an id that stands nowhere is refused too, rather than quietly adding one.
  • rule: and an id naming anything but a question of the writer’s — a fact, an answer of the user’s — is refused before a character goes.
    • why: the writer must not delete what is not done.
    • therefore: the widest a rewrite can reach is the one question it names.
(if (konix/agent-shell-workspace--goto-id id)
    (progn
      (unless (konix/agent-shell-workspace--state-at-point)
        (error "%s is no question of yours to rewrite" id))
      (when (equal (konix/agent-shell-workspace--state-at-point)
                   konix/agent-shell-workspace-done-keyword)
        (error "That question is closed — ask a new question rather than rewriting it"))
      (konix/agent-shell-workspace--refuse-the-lower
       keyword (konix/agent-shell-workspace--state-at-point))
      (let ((limit (konix/agent-shell-workspace--question-end)))
        (setq kept-answers
              (konix/agent-shell-workspace--answers-under (point) limit)
              kept-links
              (konix/agent-shell-workspace--links-under (point) limit)
              kept-said
              (konix/agent-shell-workspace--what-it-says (point) limit)
              kept-heading
              (string-trim (org-get-heading t t t t)))
        (delete-region (point) limit)))
  (when id
    (error "No heading %s in the workspace" id))
  (goto-char (point-max))
  (setq added t))
(insert question)
(when kept-answers
  (save-excursion (insert kept-answers)))
(save-excursion
  (konix/agent-shell-workspace--keep-what-it-asked
   id kept-heading kept-said))
(save-excursion
  (dolist (link kept-links)
    (konix/agent-shell-workspace--link-under
     id (car link) (cdr link)))))))
rule
and writing one says what became of its places: the hunk they carry, or why they carry none.
  • why: it looks right in the listing either way, so the writer never learns that naming the revision, or anchoring inside the change, was what was missing.
  • therefore: the silence is what sent a writer off to run git itself, first for the revision and then for the lines the change touches.
(concat (if added
            (format "Added a question at %s:%s" file line)
          (format "Rewrote %s" id))
        (cond
         ((not revised)
          (concat ". This question names no revision, so its place came out as"
                  " the file's plain lines rather than the hunk it points at:"
                  " pass revspec with the change it is about and write it"
                  " again."))
         (carries ", carrying the hunk at that place.")
         (t (concat ". No place of it falls inside the change, so it came out as"
                    " the file's plain lines: anchor a line the change touches"
                    " and it carries the hunk instead.")))))))
rule
and only a DONE question can be deleted, which is why the writer leaves one it thinks settled waiting on the user rather than closing it.
  • why: the user may want to contest a question the writer thinks is finished.
  • scope: it reaches a question, a fact standing in no state and being the writer’s to take back.
    (defun konix/agent-shell-workspace--answers-under (start limit)
      "Return what the user said under the question between START and LIMIT, or nil."
      (save-excursion
        (goto-char start)
        (forward-line 1)
        (when (re-search-forward "^\\*\\* " limit t)
          (buffer-substring (match-beginning 0) limit))))

    (defconst konix/agent-shell-workspace-writer-said "writer: "
      "What a wording of the writer's opens on, kept under the question it was.")

    (defconst konix/agent-shell-workspace-user-said "me: "
      "What an answer of the user's opens on, under the question it answers.")

    (defun konix/agent-shell-workspace--what-it-says (start limit)
      "Return the question between START and LIMIT written out as one of the writer's."
      (save-excursion
        (goto-char start)
        (concat "** " konix/agent-shell-workspace-writer-said
                (string-trim (org-get-heading t t t t)) "\n"
                "   :PROPERTIES:\n   :ID:       " (org-id-new) "\n   :END:\n"
                (mapconcat (lambda (line) (concat "   " line "\n"))
                           (konix/agent-shell-workspace--body-lines start limit)
                           ""))))

    (defun konix/agent-shell-workspace--where-a-wording-goes (heading start limit)
      "Return where the wording HEADING goes under the question START to LIMIT.
    Above the first answer given on it, and at the end where it drew none."
      (save-excursion
        (goto-char start)
        (if (re-search-forward (regexp-quote (format "« %s »" heading)) limit t)
            (progn
              (konix/agent-shell-workspace--goto-heading)
              (line-beginning-position))
          limit)))

    (defun konix/agent-shell-workspace--keep-what-it-asked (id heading asked)
      "Put ASKED, the wording HEADING the question ID had, among the answers under it."
      (when (and asked (konix/agent-shell-workspace--goto-id id))
        (goto-char (konix/agent-shell-workspace--where-a-wording-goes
                    heading (point) (konix/agent-shell-workspace--question-end)))
        (unless (bolp) (insert "\n"))
        (insert asked)))

    (defun konix/agent-shell-workspace--links-under (start limit)
      "Return (ID . LABEL) for each fact linked to from between START and LIMIT."
      (let (links)
        (save-excursion
          (goto-char start)
          (while (re-search-forward "\\[\\[id:\\([^]]+\\)\\]\\[\\([^]]*\\)\\]\\]"
                                    limit t)
            (push (cons (match-string 1) (match-string 2)) links)))
        (nreverse links)))

    (defun konix/mcp-server-delete-workspace-question (id)
      "Remove the workspace's question, fact or answer whose id is ID.

MCP Parameters:
  id - Id of the heading to remove, as the listing tool gives it"
      (mcp-server-lib-with-error-handling
       (konix/agent-shell-workspace--edit
        (lambda ()
          (unless (konix/agent-shell-workspace--goto-id id)
            (error "No heading %s in the workspace" id))
          (if (equal (org-current-level) 2)
              (delete-region (point) (konix/agent-shell-workspace--answer-end))
            (when-let ((state (konix/agent-shell-workspace--state-at-point))
                       (open (not (equal state
                                         konix/agent-shell-workspace-done-keyword))))
              (error "That question is not DONE — leave it waiting on the user to close"))
            (delete-region (point) (konix/agent-shell-workspace--question-end)))))
       (format "Dropped %s" id)))

what the writer does to a question

rule
the writer names an act, never a keyword.
  • why: the user will change their mind often on whether a thing is a keyword or a tag, and the code must absorb that.
  • what: the writer’s four are work, refine, put-down and close.
  • rule: and each act is named after the state it leaves the question in, bar the one whose state name says nothing about the move.
    • why: the states converge, so the actions must converge as well, though todo is unclear as an act and put-down is what it does.
    • therefore: the loose coupling stays — one table, and no tool spelling a keyword — while the words the writer says and the words the user reads agree.
  • rule: and settle is the user’s own act, refused the writer by name.
    • why: a writer must never move one to done; that is a move from the user.
    • how: it is named in the refusal rather than left out of it, so the writer hears whose move it is instead of thinking it mistyped.
  • therefore: the keywords become a detail of the file, and moving one to a tag or back changes one table and no tool.
rule
closing takes two words: close asks whether anything is left, and only close-for-sure closes.
  • why: the writer hands over what it should have refined, and the one moment it weighs that is the moment it is stopped and asked.
  • how: the asking is the refusal itself, which names refining as what it may have meant and names the word that closes.
  • therefore: the second word is a sentence the writer has to mean, not a key it pressed twice.
  • rule: and the word that closes is refused until that question has been asked about.
    • why: a writer that knows the second word would say it first and never weigh anything, which is the whole of what the two are for.
    • how: the asking is remembered against the question it was about, in the writer it was asked of, and is spent by the closing it allows.
    • therefore: refining after the asking is the ordinary way out of it, and the asking has to be met again if the question comes back.
  • scope: nothing else takes two: what the writer says of a question it has made a mess of is a rewrite, and a rewrite is one call as ever.
(defconst konix/agent-shell-workspace-acts
  (list (cons "work" konix/agent-shell-workspace-working-keyword)
        (cons "refine" konix/agent-shell-workspace-refine-keyword)
        (cons "put-down" konix/agent-shell-workspace-fresh-keyword)
        (cons "close" konix/agent-shell-workspace-closing-keyword)
        (cons "close-for-sure" konix/agent-shell-workspace-closing-keyword))
  "Where each act the writer can name leaves a question.")

(defconst konix/agent-shell-workspace-users-acts
  '("settle" "done" "maybe")
  "Acts that are the user's own, which the writer is refused.")

(defconst konix/agent-shell-workspace-settling-error
  (concat "Settling is the user's move, never yours. Finish it instead and leave the"
          " settling to them.")
  "What a writer is told when it tries to settle a question itself.")

(defconst konix/agent-shell-workspace-asking-act "close"
  "The act that asks rather than closes, so closing is weighed twice.")

(defconst konix/agent-shell-workspace-sure-act "close-for-sure"
  "The act that closes, said once the asking has been answered.")

(defconst konix/agent-shell-workspace-closing-error
  (concat "Is anything still to be done on it? Closing hands the user a finished"
          " thing: nothing of the work left, nothing unclear about what they asked."
          " If what it asks is unclear, or you expect something from the user, or"
          " you guessed at any of it, refine it"
          " instead and say what you need. If it is truly finished, say"
          " close-for-sure.")
  "What a writer is told when it says close, so it weighs refining once more.")

(defconst konix/agent-shell-workspace-unasked-error
  (concat "Say close on that one first, and read what it asks you: closing is"
          " weighed before it is said, and that asking is where you see whether"
          " refining is what you meant.")
  "What a writer is told when it closes one it was never asked about.")

(defvar-local konix/agent-shell-workspace--asked-to-close nil
  "Questions this writer has been asked whether anything is left of.")

(defun konix/agent-shell-workspace--asking-about (id remember)
  "Remember REMEMBER of ID in the writer, and say what it knew of it before."
  (when-let* ((id id)
              (writer (konix/agent-shell-workspace--writer))
              ((buffer-live-p writer)))
    (with-current-buffer writer
      (prog1 (and (member id konix/agent-shell-workspace--asked-to-close) t)
        (setq-local konix/agent-shell-workspace--asked-to-close
                    (if remember
                        (cons id konix/agent-shell-workspace--asked-to-close)
                      (delete id konix/agent-shell-workspace--asked-to-close)))))))

(defun konix/agent-shell-workspace--act-keyword (act &optional id)
  "Return where ACT leaves the question ID, refusing a word that names no act."
  (let ((named (string-trim (or act ""))))
    (when (member-ignore-case named konix/agent-shell-workspace-users-acts)
      (error "%s" konix/agent-shell-workspace-settling-error))
    (when (string-equal-ignore-case named konix/agent-shell-workspace-asking-act)
      (konix/agent-shell-workspace--asking-about id t)
      (error "%s" konix/agent-shell-workspace-closing-error))
    (when (and (string-equal-ignore-case named konix/agent-shell-workspace-sure-act)
               id
               (not (konix/agent-shell-workspace--asking-about id nil)))
      (error "%s" konix/agent-shell-workspace-unasked-error))
    (unless (string-equal-ignore-case named konix/agent-shell-workspace-sure-act)
      (konix/agent-shell-workspace--asking-about id nil))
    (or (cdr (assoc-string named konix/agent-shell-workspace-acts t))
        (error "Unknown act \"%s\" — one of: %s" act
               (mapconcat #'car konix/agent-shell-workspace-acts ", ")))))
what
an act by its id replaces that heading’s keyword and leaves every other character of the heading alone.
  • why: moving across the states without erasing the body is what saves tokens.
  • scope: that holds of every workspace these tools write; a line of the user’s starting on a star, pasted into a body by hand, reads as a heading to the pass that makes the file well formed, and that heading it may touch.
    (defun konix/mcp-server-set-workspace-state (id &optional act keyword)
      "Perform ACT on the workspace's question whose id is ID.

Every other character of that heading is left alone.  KEYWORD is the name ACT
went by before, taken while a session that opened on the old schema is still
running.

MCP Parameters:
  id - Id of the question to act on, as the listing tool gives it
  act - work, refine, put-down, close, then close-for-sure
  keyword - What act was called before; pass act instead"
      (mcp-server-lib-with-error-handling
       (setq act (or act keyword))
       (konix/agent-shell-workspace--act-said
        act id
        (konix/agent-shell-workspace--edit
         (lambda ()
           (let* ((keyword (konix/agent-shell-workspace--act-keyword act id))
                  (was (konix/agent-shell-workspace--refuse-the-act id keyword))
                  (word-start (+ (line-beginning-position)
                                 (1+ (org-current-level))))
                  (word-end (+ word-start (length was))))
             (delete-region word-start word-end)
             (goto-char word-start)
             (insert keyword)))))))

what an act is refused

rule
it refuses before it writes, so a refused call leaves the heading as it was.
  • rule: an id standing nowhere is refused.
  • rule: a fact is refused, standing in no state.
  • rule: an answer is refused, being the user’s and not the writer’s to move.
  • rule: and a word naming no act is refused, the refusal naming the acts there are.
rule
no act reaches a question the user put off.
  • how: no act names that state, so putting one off is not something to refuse — it is something the writer cannot ask for.
rule
and taking up one standing below the highest of its own is refused, the refusal naming the one to take instead.
  • scope: only taking up: putting down, refining and closing reach whatever the writer holds, whatever it stands at.
  • rule: and writing a question refuses it on the same terms, an act riding on a rewrite being the same taking up by another door.
    • why: the refusal sat on the one tool that names an act, and the other names one too, so the writer took what it liked by rewriting it.
rule
and no act reaches one that waits on the user.
  • why: taking it up would answer it for them.
  • therefore: putting one down does not reach it either, or the writer takes their turn away to get at what holding a question unlocks.
  • how: it comes back to the writer of itself the moment they say anything, so there is nothing to do but wait.
(defconst konix/agent-shell-workspace-put-off-error
  (concat "The user put that question off. Leave it alone and take up one they have"
          " not.")
  "What a writer is told when it goes for a question the user put off.")

(defconst konix/agent-shell-workspace-waiting-error
  (concat "That question waits on the user, so no act of yours reaches it. Wait: it"
          " comes back to you the moment they say anything.")
  "What a writer is told when it acts on a question waiting on the user.")

(defconst konix/agent-shell-workspace-lower-error
  (concat "The user has put another of yours higher: « %s ». Take that one, or one"
          " standing as high. This one comes back to you once the higher ones are no"
          " longer yours.")
  "What a writer is told when it takes up one below the highest it has.")

(defun konix/agent-shell-workspace--refuse-the-lower (keyword was)
  "Refuse KEYWORD on the question at point, which WAS, when another stands higher.
Only taking one up is refused, and only where the writer has a higher one of its own."
  (when (and (equal keyword konix/agent-shell-workspace-working-keyword)
             (not (equal was konix/agent-shell-workspace-working-keyword)))
    (let ((here (konix/agent-shell-workspace--standing-at-point))
          (highest (konix/agent-shell-workspace--standing-highest)))
      (when (and highest (< here (car highest)))
        (error "%s" (format konix/agent-shell-workspace-lower-error
                            (cdr highest)))))))

(defun konix/agent-shell-workspace--refuse-the-act (id keyword)
  "Refuse KEYWORD on the question ID, or return the state it stands in.
Point is left on its heading and not a character of it is written."
  (unless (konix/agent-shell-workspace--goto-id id)
    (error "No heading %s in the workspace" id))
  (when (equal (org-current-level) 2)
    (error "An answer is the user's to move, not yours: %s" id))
  (let ((was (or (konix/agent-shell-workspace--state-at-point)
                 (error "A fact stands in no state, so there is none to act on"))))
    (when (and (equal keyword konix/agent-shell-workspace-working-keyword)
               (not (equal was konix/agent-shell-workspace-working-keyword))
               (konix/agent-shell-workspace--working-p))
      (error "%s" (concat "You already hold a question."
                          " Put that one down first, or work on it")))
    (when (equal was konix/agent-shell-workspace-later-keyword)
      (error "%s" konix/agent-shell-workspace-put-off-error))
    (konix/agent-shell-workspace--refuse-the-lower keyword was)
    (when (member was konix/agent-shell-workspace-users-keywords)
      (error "%s" konix/agent-shell-workspace-waiting-error))
    (when (and (member keyword konix/agent-shell-workspace-users-keywords)
               (konix/agent-shell-workspace--nothing-written-p))
      (error "%s" konix/agent-shell-workspace-empty-handover-error))
    was))

what taking one up says

rule
taking one up is answered by insisting on what it commits the writer to, and by the goal, said again in full.
  • why: the writer takes one up and then wanders off into whatever it notices next, and the one moment it is certainly listening is the answer to that call.
  • rule: and what it committed to is said in the question’s own words, quoted, not as « that one ».
    • why: a writer told it has committed to « that one » has been told nothing it can hold itself to, and it is the words it drifts from.
    • scope: a question whose words cannot be read back is « that one » still, the answer being worth saying either way.
  • what: it holds that one alone, whatever else it sees is a question to leave with the user, and every doubt of its own is asked of them about that one.
  • rule: and it is told to hand that one back the moment what it asks is unclear, rather than to guess at it.
    • why: a guess costs the work it sends the writer off to do and the reading of it, where asking costs the user a sentence.
    • therefore: handing one back is the ordinary move it is, not the admission of defeat « only when you cannot go on » made of it.
  • rule: and where no goal is named, it is told to ask what the work is.
    • why: « in the context of the goal » is nothing to a writer whose workspace names none.
(defun konix/agent-shell-workspace--heading-of (id file)
  "Return what the question ID asks in FILE, or nil where it names none."
  (when (and id file (file-readable-p file))
    (konix/agent-shell-workspace--read-file file
      (when (konix/agent-shell-workspace--goto-id id)
        (org-get-heading t t t t)))))

(defun konix/agent-shell-workspace--act-said (act id file)
  "Return what a writer is told of ACT on ID, FILE being the workspace it is in."
  (string-trim
   (concat (format "%s: %s" act id)
           (when (equal (konix/agent-shell-workspace--act-keyword act)
                        konix/agent-shell-workspace-working-keyword)
             (let ((goal (konix/agent-shell-workspace--goal-line file))
                   (asked (konix/agent-shell-workspace--heading-of id file)))
               (concat "\n\n"
                       (format konix/agent-shell-workspace-holding-said
                               (if asked (format "« %s »" asked) "that one"))
                       "\n\n"
                       (if (string-empty-p goal)
                           konix/agent-shell-workspace-no-goal-said
                         goal)))))))
rule
and taking one up is refused while the writer already holds one.
  • scope: putting down what the writer holds is never refused, that one being its own by definition.
  • how: the refusal names what to do with the one it holds.
(defun konix/agent-shell-workspace--working-p ()
  "Non-nil when a question of this buffer is one the writer is on."
  (save-excursion
    (goto-char (point-min))
    (konix/agent-shell-workspace--search-state
     (concat "^\\* " konix/agent-shell-workspace-working-keyword " "))))
rule
and an act that hands a question over with nothing written under it is refused.
  • why: the user won’t know what to do about it.
  • scope: it reaches refine and close, the two that hand it over.
  • therefore: todo is the one act that needs nothing said, which is what makes it the way out.
  • how: what is written is what the listing reads back, so a heading carrying only the place it names counts as carrying nothing.
  • scope: a subject the user raised themselves is spared, since the writer can neither add to it nor rewrite it, and they know already what they meant by it.
    • how: it asks nothing, which is what tells it from a question of the writer’s, every one of those being refused unless it ends in a question mark.
      • scope: naming a place does not tell them apart, a subject raised from a region naming one too.
(defconst konix/agent-shell-workspace-empty-handover-error
  (concat "Nothing is written under that question, so handing it to the user says"
          " nothing. Say what you are asking in its body, or leave it in TODO, which"
          " touches not a word of it.")
  "What a writer is told when it hands over a question carrying no body.")

(defun konix/agent-shell-workspace--the-users-own-p ()
  "Non-nil when the heading at point asks nothing, so the user raised it."
  (save-excursion
    (beginning-of-line)
    (not (string-suffix-p
          "?" (string-trim (buffer-substring-no-properties
                            (point) (line-end-position)))))))

(defun konix/agent-shell-workspace--nothing-written-p ()
  "Non-nil when nothing is written under the question at point.
A subject the user raised themselves counts as written, whatever it carries."
  (and (not (konix/agent-shell-workspace--the-users-own-p))
       (null (konix/agent-shell-workspace--body-lines
              (line-beginning-position)
              (konix/agent-shell-workspace--question-end)))))

reading them back

what
reading the headings back is one of these tools, and it is the first thing the writer does on being told there is something for it.
(defun konix/agent-shell-workspace--listing (file &optional only-actionable)
  "Return the workspace FILE's headings with what is written under them, or nil.
ONLY-ACTIONABLE keeps back whatever the writer has nothing to do about."
  (konix/agent-shell-workspace--read-file file
    (goto-char (point-min))
    (let ((highest (and only-actionable
                        (car (konix/agent-shell-workspace--standing-highest))))
          rows)
      (while (konix/agent-shell-workspace--search-state
              (concat konix/agent-shell-workspace-state-regexp "\\(.*\\)$"))
        (let* ((state (match-string 1))
               (heading (match-string 2))
               (start (line-beginning-position))
               (limit (konix/agent-shell-workspace--question-end))
               (id (save-excursion
                     (when (re-search-forward "^ *:ID: *\\(.+\\)$" limit t)
                       (string-trim (match-string 1)))))
               (hunked (save-excursion
                         (goto-char start)
                         (and (org-entry-get (point) "REVSPEC") t))))
          (when (or (not only-actionable)
                    (and (member state konix/agent-shell-workspace-writers-keywords)
                         (or (null highest)
                             (equal state
                                    konix/agent-shell-workspace-working-keyword)
                             (>= (save-excursion
                                   (goto-char start)
                                   (konix/agent-shell-workspace--standing-at-point))
                                 highest))))
            (push (format "%s %s %s — %s"
                          (konix/agent-shell-workspace--standing state)
                          (or id "-")
                          (if (re-search-forward
                               konix/agent-shell-workspace-link-regexp limit t)
                              (format "%s:%s%s"
                                      (match-string 1) (match-string 2)
                                      (if hunked " (carrying its hunk)" ""))
                            "?")
                          heading)
                  rows)
            (dolist (line (konix/agent-shell-workspace--body-lines start limit))
              (push (concat "    " line) rows))
            (save-excursion
              (goto-char start)
              (forward-line 1)
              (while (re-search-forward "^\\*\\* \\(.*\\)$" limit t)
                (let* ((answer (match-string 1))
                       (its-id (save-excursion
                                 (when (re-search-forward "^ *:ID: *\\(.+\\)$" limit t)
                                   (string-trim (match-string 1))))))
                  (push (format "  %s — %s" (or its-id "-") answer) rows)
                  (dolist (line (konix/agent-shell-workspace--body-lines
                                 (line-beginning-position) limit))
                    (push (concat "      " line) rows))))))
          (goto-char limit)))
      (goto-char (point-min))
      (while (re-search-forward "^\\* \\(.*\\)$" nil t)
        (let* ((line (match-string 0))
               (heading (match-string 1))
               (limit (konix/agent-shell-workspace--question-end))
               (id (save-excursion
                     (when (re-search-forward "^ *:ID: *\\(.+\\)$" limit t)
                       (string-trim (match-string 1))))))
          (unless (or (konix/agent-shell-workspace--state-in line)
                      only-actionable)
            (push (format "FACT %s — %s" (or id "-") heading) rows))
          (goto-char limit)))
      (when rows
        (string-join (nreverse rows) "\n")))))

(defun konix/mcp-server-list-workspace-questions ()
  "List the workspace's headings with what is written under them.

A question comes with its state and place, then a fact."
  (mcp-server-lib-with-error-handling
   (let ((file (konix/agent-shell-workspace--file-or-error)))
     (unless (file-readable-p file)
       (error "No workspace in %s" file))
     (concat (konix/agent-shell-workspace--goal-line file)
             (or (konix/agent-shell-workspace--listing file)
                 "The workspace has nothing in it")))))
what
the listing has to say enough to act on any of them next: which heading it is, what is written under it, and for a question the place it names and whose turn it is.
rule
and it says whose turn it is in a word of its own, never the file’s keyword.
  • why: fix the coupling at the reading end too, not only where the writer writes.
  • what: yours, held, asked, finished, settled, later.
  • therefore: the writer never learns a keyword from either end, so moving one to a tag changes one table and nothing it reads or says.
(defconst konix/agent-shell-workspace-standings
  (list (cons konix/agent-shell-workspace-fresh-keyword "yours")
        (cons konix/agent-shell-workspace-working-keyword "held")
        (cons konix/agent-shell-workspace-refine-keyword "asked")
        (cons konix/agent-shell-workspace-closing-keyword "finished")
        (cons konix/agent-shell-workspace-done-keyword "settled")
        (cons konix/agent-shell-workspace-later-keyword "later"))
  "What the listing calls each keyword, so no keyword reaches the writer.")

(defun konix/agent-shell-workspace--standing (keyword)
  "Return what the listing calls KEYWORD, or KEYWORD where it calls it nothing."
  (or (cdr (assoc keyword konix/agent-shell-workspace-standings)) keyword))
what
what is written runs from the heading down to the first place, so a subject of the user’s comes back whether it was put in bullets or not.
  • why: the writer needs to read before choosing, to choose knowingly.
  • scope: a fact’s body stays out, no choice of the writer’s turning on a heading that is the user’s to read rather than to act on.
  • scope: and the code of every place a question names stays out, the place bullets with it, so what comes back is what somebody wrote and not what the question shows.
    • rule: but a place says that it carries its hunk, where that question names a revision.
      • why: a writer reading its own question back saw only a link, concluded the hunk was never written, and went off to run git for it.
  • scope: what org keeps between the heading and the body is stepped over, and anything of org’s met after the body has begun ends it, so none of it comes back.
(while (konix/agent-shell-workspace--search-state
        (concat konix/agent-shell-workspace-state-regexp "\\(.*\\)$"))
  (let* ((state (match-string 1))
         (heading (match-string 2))
         (start (line-beginning-position))
         (limit (konix/agent-shell-workspace--question-end))
         (id (save-excursion
               (when (re-search-forward "^ *:ID: *\\(.+\\)$" limit t)
                 (string-trim (match-string 1)))))
         (hunked (save-excursion
                   (goto-char start)
                   (and (org-entry-get (point) "REVSPEC") t))))
    (when (or (not only-actionable)
              (and (member state konix/agent-shell-workspace-writers-keywords)
                   (or (null highest)
                       (equal state
                              konix/agent-shell-workspace-working-keyword)
                       (>= (save-excursion
                             (goto-char start)
                             (konix/agent-shell-workspace--standing-at-point))
                           highest))))
      (push (format "%s %s %s — %s"
                    (konix/agent-shell-workspace--standing state)
                    (or id "-")
                    (if (re-search-forward
                         konix/agent-shell-workspace-link-regexp limit t)
                        (format "%s:%s%s"
                                (match-string 1) (match-string 2)
                                (if hunked " (carrying its hunk)" ""))
                      "?")
                    heading)
            rows)
      (dolist (line (konix/agent-shell-workspace--body-lines start limit))
        (push (concat "    " line) rows))
      (save-excursion
        (goto-char start)
        (forward-line 1)
        (while (re-search-forward "^\\*\\* \\(.*\\)$" limit t)
          (let* ((answer (match-string 1))
                 (its-id (save-excursion
                           (when (re-search-forward "^ *:ID: *\\(.+\\)$" limit t)
                             (string-trim (match-string 1))))))
            (push (format "  %s — %s" (or its-id "-") answer) rows)
            (dolist (line (konix/agent-shell-workspace--body-lines
                           (line-beginning-position) limit))
              (push (concat "      " line) rows))))))
    (goto-char limit)))
(defun konix/agent-shell-workspace--body-lines (start limit)
  "Return what is written between START and LIMIT, stopping at the first place.

Org's own bookkeeping between the heading and the body — a planning line, a drawer —
is stepped over, and anything of org's met after the body has begun ends it, so none
of it is ever returned."
  (let (lines)
    (save-excursion
      (goto-char start)
      (forward-line 1)
      (while (or (looking-at org-planning-line-re)
                 (and (looking-at org-drawer-regexp)
                      (re-search-forward org-property-end-re limit t)))
        (forward-line 1))
      (while (and (< (point) limit)
                  (not (looking-at "^\\*+ "))
                  (not (looking-at org-planning-line-re))
                  (not (looking-at org-drawer-regexp))
                  (not (looking-at
                        konix/agent-shell-workspace-pointing-regexp)))
        (let ((line (string-trim
                     (buffer-substring (line-beginning-position)
                                       (line-end-position)))))
          (unless (string-empty-p line) (push line lines)))
        (forward-line 1)))
    (nreverse lines)))
what
a fact comes out after them, named by its id, since that id is what a question has to link to.
(goto-char (point-min))
(while (re-search-forward "^\\* \\(.*\\)$" nil t)
  (let* ((line (match-string 0))
         (heading (match-string 1))
         (limit (konix/agent-shell-workspace--question-end))
         (id (save-excursion
               (when (re-search-forward "^ *:ID: *\\(.+\\)$" limit t)
                 (string-trim (match-string 1))))))
    (unless (or (konix/agent-shell-workspace--state-in line)
                only-actionable)
      (push (format "FACT %s — %s" (or id "-") heading) rows))
    (goto-char limit)))
what
an answer of the user’s comes out indented under its question, so the writer sees what it has not taken up yet.
(save-excursion
  (goto-char start)
  (forward-line 1)
  (while (re-search-forward "^\\*\\* \\(.*\\)$" limit t)
    (let* ((answer (match-string 1))
           (its-id (save-excursion
                     (when (re-search-forward "^ *:ID: *\\(.+\\)$" limit t)
                       (string-trim (match-string 1))))))
      (push (format "  %s — %s" (or its-id "-") answer) rows)
      (dolist (line (konix/agent-shell-workspace--body-lines
                     (line-beginning-position) limit))
        (push (concat "      " line) rows)))))

binding a workspace itself

what
the writer that starts a workspace names the document itself, and the note it is working on is the obvious one.
rule
it only binds a session with nothing bound to it yet, and says whose the rebinding is.
rule
a writer that binds its own workspace is told how the work goes like any other, since naming a document is not knowing what is expected in it.
  • scope: it is told no goal, having nobody to be told what the work is by.
    (defun konix/mcp-server-set-workspace (file)
      "Bind FILE as the document this session writes its questions into.

MCP Parameters:
  file - Absolute path of the Org document to work in"
      (mcp-server-lib-with-error-handling
       (let ((writer (konix/agent-shell-workspace--writer))
             (file (expand-file-name (decode-coding-string file 'utf-8))))
         (unless (buffer-live-p writer)
           (error "Cannot identify the calling session"))
         (when-let ((bound (konix/agent-shell-workspace-file writer)))
           (error "This session already writes into %s; ask the user to rebind it"
                  bound))
         (unless (string-suffix-p ".org" file)
           (error "A workspace has to be an Org file: %s" file))
         (konix/agent-shell-workspace--make-unless-there file)
         (konix/agent-shell-workspace--write-file file
           (konix/agent-shell-workspace--ensure-well-formed)
           (konix/agent-shell-workspace--ensure-session-link writer))
         (konix/agent-shell-workspace--remember writer file)
         (with-current-buffer writer
           (setq-local konix/agent-shell-workspace--briefed t))
         (konix/agent-shell-workspace-binding-briefing file))))

a diff of the writer’s own choosing

what
the writer can ask for a diff of its own choosing, for the times the subject is a change rather than a document.
how
it says which repository to ask, since the buffer the user is sitting in is rarely the right one.
rule
and the reading is made ready and left where it is, never put in front of the user.
  • why: asking for one is the writer working, and the user was reading something else when it did.
  • how: what comes back names the buffer it made, which is how the writer says where it is.
  • therefore: the user opens it when they want it, and d on a question stays the way a diff comes to them by itself.
    (defun konix/mcp-server-show-diff (revspec &optional paths directory)
      "Read `git diff REVSPEC -- PATHS' into a `diff-mode' buffer, shown to nobody.

MCP Parameters:
  revspec - Revision or range to diff, as git would take it
  paths - Optional JSON array of paths to restrict the diff to
  directory - Repository to run git in, defaulting to the user's buffer"
      (mcp-server-lib-with-error-handling
       (let ((buffer (konix/agent-shell-workspace--render-diff
                      revspec
                      (konix/mcp-server-decode-json-list paths)
                      (or directory
                          (with-current-buffer (window-buffer (selected-window))
                            default-directory)))))
         (format "diff of %s in %s" revspec (buffer-name buffer)))))